kiwimatecoder 0.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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/shell.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Persistent shell sessions and background process management.
|
|
2
|
+
|
|
3
|
+
The ``run_bash`` tool starts a fresh process for every call, so ``cd`` and
|
|
4
|
+
exported environment variables never survive between commands. This module keeps
|
|
5
|
+
one long-lived shell per session instead: commands are written to the shell's
|
|
6
|
+
stdin and a unique sentinel line (carrying ``$?``) is echoed after each one, so
|
|
7
|
+
output and exit code can be attributed to that exact command.
|
|
8
|
+
|
|
9
|
+
Background commands are intentionally separate from the persistent shell: each
|
|
10
|
+
one runs as a detached process with its stdout/stderr redirected to a file under
|
|
11
|
+
``~/.kiwimatecoder/jobs/shell/``, so it keeps running after the shell exits.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from kiwimatecoder.config import ensure_config_dir, get_shell_config
|
|
29
|
+
from kiwimatecoder.remote import wrap_remote_command
|
|
30
|
+
from kiwimatecoder.sandbox import wrap_command
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from kiwimatecoder.session import Session
|
|
34
|
+
|
|
35
|
+
MAX_OUTPUT = 30_000
|
|
36
|
+
KILL_GRACE_SECONDS = 5.0
|
|
37
|
+
_POLL_INTERVAL = 0.05
|
|
38
|
+
_SENTINEL_RE = re.compile(r"^__KIWI_([0-9a-f]{12})__\s+(-?\d+)\s*$")
|
|
39
|
+
# Windows has no SIGKILL; a forced kill there is TerminateProcess either way.
|
|
40
|
+
_SIGKILL: int = getattr(signal, "SIGKILL", signal.SIGTERM)
|
|
41
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
42
|
+
_ERROR_ACCESS_DENIED = 5
|
|
43
|
+
_STILL_ACTIVE = 259
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ShellError(RuntimeError):
|
|
47
|
+
"""Raised for persistent-shell and background-job failures."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ShellDisabledError(ShellError):
|
|
51
|
+
"""Raised when the persistent shell is disabled in config."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ShellJobLimitError(ShellError):
|
|
55
|
+
"""Raised when starting a background job would exceed the configured cap."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _truncate(text: str, limit: int = MAX_OUTPUT) -> str:
|
|
59
|
+
if len(text) <= limit:
|
|
60
|
+
return text
|
|
61
|
+
return text[:limit] + "\n... [output truncated]"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bounded_output(text: str, truncated: bool) -> str:
|
|
65
|
+
"""Bound command output, adding a marker when the reader dropped content."""
|
|
66
|
+
if not truncated:
|
|
67
|
+
return _truncate(text)
|
|
68
|
+
if len(text) > MAX_OUTPUT:
|
|
69
|
+
text = text[:MAX_OUTPUT]
|
|
70
|
+
return text + "\n... [output truncated]"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_windows() -> bool:
|
|
74
|
+
return os.name == "nt"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _find_windows_bash() -> str | None:
|
|
78
|
+
"""Find Git for Windows' ``bash.exe``; WSL's launcher in System32 cannot be used."""
|
|
79
|
+
roots = [os.environ.get(n) for n in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)")]
|
|
80
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
81
|
+
if local:
|
|
82
|
+
roots.append(os.path.join(local, "Programs"))
|
|
83
|
+
for root in roots:
|
|
84
|
+
if root:
|
|
85
|
+
candidate = os.path.join(root, "Git", "bin", "bash.exe")
|
|
86
|
+
if os.path.isfile(candidate):
|
|
87
|
+
return candidate
|
|
88
|
+
found = shutil.which("bash")
|
|
89
|
+
if not found or not os.path.isabs(found):
|
|
90
|
+
# shutil.which tries the current directory first on Windows and reports a hit
|
|
91
|
+
# there as ".\bash.exe"; that is a file in the workspace, never a shell to run.
|
|
92
|
+
return None
|
|
93
|
+
system_root = os.environ.get("SystemRoot")
|
|
94
|
+
if system_root and os.path.normcase(found).startswith(os.path.normcase(system_root)):
|
|
95
|
+
return None
|
|
96
|
+
return found
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _shell_argv() -> list[str]:
|
|
100
|
+
"""Argv of the persistent shell: ``/bin/sh``, or Git Bash on Windows.
|
|
101
|
+
|
|
102
|
+
The sentinel protocol (``printf`` and ``$?``) and the ``cd``/``export`` the tool
|
|
103
|
+
promises are POSIX shell features. cmd.exe prints a prompt before every command, so
|
|
104
|
+
the marker never starts a line and every call would time out; fail at once instead.
|
|
105
|
+
"""
|
|
106
|
+
if _is_windows():
|
|
107
|
+
bash = _find_windows_bash()
|
|
108
|
+
if bash is None:
|
|
109
|
+
raise ShellError(
|
|
110
|
+
"The persistent shell needs a POSIX shell on Windows and none was found. "
|
|
111
|
+
"Install Git for Windows (Git Bash), or turn the persistent shell off with "
|
|
112
|
+
"`config shell persistent off` and use run_bash."
|
|
113
|
+
)
|
|
114
|
+
return [bash]
|
|
115
|
+
return ["/bin/sh"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _write_stdin(proc: subprocess.Popen[str], payload: str) -> None:
|
|
119
|
+
"""Send ``payload`` to the shell without newline translation.
|
|
120
|
+
|
|
121
|
+
A text-mode pipe on Windows turns every ``"\\n"`` into ``"\\r\\n"``, and a POSIX shell then
|
|
122
|
+
reads the ``"\\r"`` as part of the command (``cd sub\\r``, ``(exit 3)\\r``). The bytes go to
|
|
123
|
+
the binary layer instead; POSIX keeps using the text layer.
|
|
124
|
+
"""
|
|
125
|
+
stdin = proc.stdin
|
|
126
|
+
assert stdin is not None
|
|
127
|
+
raw = getattr(stdin, "buffer", None) if _is_windows() else None
|
|
128
|
+
if raw is not None:
|
|
129
|
+
raw.write(payload.encode("utf-8"))
|
|
130
|
+
raw.flush()
|
|
131
|
+
return
|
|
132
|
+
stdin.write(payload)
|
|
133
|
+
stdin.flush()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _windows_pid_alive(pid: int) -> bool:
|
|
137
|
+
"""Win32 liveness check; ``os.kill(pid, 0)`` there sends CTRL_C_EVENT instead."""
|
|
138
|
+
import ctypes
|
|
139
|
+
from ctypes import wintypes
|
|
140
|
+
|
|
141
|
+
kernel32 = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True)
|
|
142
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
143
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
144
|
+
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
145
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
146
|
+
handle = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
147
|
+
if not handle:
|
|
148
|
+
# Access denied still means the process exists, it just belongs to someone else.
|
|
149
|
+
return bool(getattr(ctypes, "get_last_error")() == _ERROR_ACCESS_DENIED)
|
|
150
|
+
try:
|
|
151
|
+
code = wintypes.DWORD()
|
|
152
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
153
|
+
return True
|
|
154
|
+
return bool(code.value == _STILL_ACTIVE)
|
|
155
|
+
finally:
|
|
156
|
+
kernel32.CloseHandle(handle)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _kill_tree_windows(pid: int) -> None:
|
|
160
|
+
"""Kill ``pid`` and its descendants; Windows has no process group to signal."""
|
|
161
|
+
try:
|
|
162
|
+
subprocess.run(
|
|
163
|
+
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
164
|
+
stdin=subprocess.DEVNULL,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
timeout=10,
|
|
167
|
+
check=False,
|
|
168
|
+
)
|
|
169
|
+
except (OSError, subprocess.SubprocessError):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pid_alive(pid: int | None) -> bool:
|
|
174
|
+
"""Whether ``pid`` exists, tolerating permission errors as "alive"."""
|
|
175
|
+
if not pid or pid <= 0:
|
|
176
|
+
return False
|
|
177
|
+
if _is_windows():
|
|
178
|
+
return _windows_pid_alive(pid)
|
|
179
|
+
try:
|
|
180
|
+
os.kill(pid, 0)
|
|
181
|
+
except ProcessLookupError:
|
|
182
|
+
return False
|
|
183
|
+
except PermissionError:
|
|
184
|
+
return True
|
|
185
|
+
except OSError:
|
|
186
|
+
return False
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _send_signal(proc: subprocess.Popen[Any], sig: int) -> None:
|
|
191
|
+
"""Signal the process group when possible, falling back to the process."""
|
|
192
|
+
if _is_windows():
|
|
193
|
+
# The shell wrapper's children would otherwise outlive it and keep our pipes open.
|
|
194
|
+
_kill_tree_windows(proc.pid)
|
|
195
|
+
try:
|
|
196
|
+
proc.kill()
|
|
197
|
+
except OSError:
|
|
198
|
+
pass
|
|
199
|
+
return
|
|
200
|
+
try:
|
|
201
|
+
os.killpg(os.getpgid(proc.pid), sig)
|
|
202
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
203
|
+
try:
|
|
204
|
+
if sig == signal.SIGTERM:
|
|
205
|
+
proc.terminate()
|
|
206
|
+
else:
|
|
207
|
+
proc.kill()
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _terminate_process(proc: subprocess.Popen[Any], *, grace: float = KILL_GRACE_SECONDS) -> None:
|
|
213
|
+
"""Terminate a process (group), escalate to SIGKILL, and reap it."""
|
|
214
|
+
if proc.poll() is not None:
|
|
215
|
+
return
|
|
216
|
+
_send_signal(proc, signal.SIGTERM)
|
|
217
|
+
try:
|
|
218
|
+
proc.wait(timeout=grace)
|
|
219
|
+
return
|
|
220
|
+
except subprocess.TimeoutExpired:
|
|
221
|
+
pass
|
|
222
|
+
_send_signal(proc, _SIGKILL)
|
|
223
|
+
try:
|
|
224
|
+
proc.wait(timeout=2.0)
|
|
225
|
+
except subprocess.TimeoutExpired:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _kill_pid(pid: int | None, *, grace: float = KILL_GRACE_SECONDS) -> None:
|
|
230
|
+
"""Terminate a process we do not own a ``Popen`` handle for."""
|
|
231
|
+
if not _pid_alive(pid):
|
|
232
|
+
return
|
|
233
|
+
assert pid is not None
|
|
234
|
+
if _is_windows():
|
|
235
|
+
_kill_tree_windows(pid)
|
|
236
|
+
return
|
|
237
|
+
try:
|
|
238
|
+
os.kill(pid, signal.SIGTERM)
|
|
239
|
+
except OSError:
|
|
240
|
+
return
|
|
241
|
+
deadline = time.monotonic() + grace
|
|
242
|
+
while time.monotonic() < deadline:
|
|
243
|
+
if not _pid_alive(pid):
|
|
244
|
+
return
|
|
245
|
+
time.sleep(_POLL_INTERVAL)
|
|
246
|
+
try:
|
|
247
|
+
os.kill(pid, signal.SIGKILL)
|
|
248
|
+
except OSError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _shell_jobs_dir() -> Path:
|
|
253
|
+
path = ensure_config_dir() / "jobs" / "shell"
|
|
254
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
try:
|
|
256
|
+
os.chmod(path, 0o700)
|
|
257
|
+
except OSError:
|
|
258
|
+
pass
|
|
259
|
+
return path
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class PersistentShell:
|
|
263
|
+
"""A long-lived shell subprocess that keeps ``cd``/env state between calls."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, workspace: Path | str, *, timeout: float | None = None) -> None:
|
|
266
|
+
self.workspace = Path(workspace)
|
|
267
|
+
self.timeout = float(timeout) if timeout is not None else None
|
|
268
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
269
|
+
self._reader: threading.Thread | None = None
|
|
270
|
+
self._cond = threading.Condition()
|
|
271
|
+
self._buffer = ""
|
|
272
|
+
self._truncated = False
|
|
273
|
+
self._collecting = False
|
|
274
|
+
self._pending_id: str | None = None
|
|
275
|
+
self._exit_code: int | None = None
|
|
276
|
+
self._eof = False
|
|
277
|
+
self._closed = False
|
|
278
|
+
self._start()
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def alive(self) -> bool:
|
|
282
|
+
proc = self._proc
|
|
283
|
+
return proc is not None and proc.poll() is None
|
|
284
|
+
|
|
285
|
+
def _start(self) -> None:
|
|
286
|
+
with self._cond:
|
|
287
|
+
self._closed = False
|
|
288
|
+
self._eof = False
|
|
289
|
+
self._buffer = ""
|
|
290
|
+
self._truncated = False
|
|
291
|
+
self._collecting = False
|
|
292
|
+
self._pending_id = None
|
|
293
|
+
self._exit_code = None
|
|
294
|
+
try:
|
|
295
|
+
shell_command = "exec /bin/sh"
|
|
296
|
+
# Remote execution decides where the shell lives; a local sandbox
|
|
297
|
+
# only applies when the shell is not remote.
|
|
298
|
+
remote_argv, _remote_warning = wrap_remote_command(
|
|
299
|
+
shell_command, workspace=self.workspace, interactive=True
|
|
300
|
+
)
|
|
301
|
+
if remote_argv is not None:
|
|
302
|
+
argv = remote_argv
|
|
303
|
+
else:
|
|
304
|
+
argv = _shell_argv()
|
|
305
|
+
if os.name != "nt":
|
|
306
|
+
# Keep the local shell inside the OS sandbox when enabled.
|
|
307
|
+
argv, _warning = wrap_command(
|
|
308
|
+
shell_command, workspace=self.workspace
|
|
309
|
+
)
|
|
310
|
+
proc = subprocess.Popen(
|
|
311
|
+
argv,
|
|
312
|
+
cwd=str(self.workspace),
|
|
313
|
+
stdin=subprocess.PIPE,
|
|
314
|
+
stdout=subprocess.PIPE,
|
|
315
|
+
stderr=subprocess.STDOUT,
|
|
316
|
+
text=True,
|
|
317
|
+
encoding="utf-8" if _is_windows() else None,
|
|
318
|
+
errors="replace",
|
|
319
|
+
bufsize=1,
|
|
320
|
+
start_new_session=True,
|
|
321
|
+
)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
raise ShellError(f"Failed to start the persistent shell: {exc}") from exc
|
|
324
|
+
self._proc = proc
|
|
325
|
+
self._reader = threading.Thread(
|
|
326
|
+
target=self._read_loop,
|
|
327
|
+
args=(proc,),
|
|
328
|
+
name="kiwi-shell-reader",
|
|
329
|
+
daemon=True,
|
|
330
|
+
)
|
|
331
|
+
self._reader.start()
|
|
332
|
+
|
|
333
|
+
def _sentinel_command(self, command_id: str) -> str:
|
|
334
|
+
return f"printf '\\n__KIWI_{command_id}__ %s\\n' $?"
|
|
335
|
+
|
|
336
|
+
def _read_loop(self, proc: subprocess.Popen[str]) -> None:
|
|
337
|
+
stream = proc.stdout
|
|
338
|
+
if stream is None:
|
|
339
|
+
return
|
|
340
|
+
try:
|
|
341
|
+
while True:
|
|
342
|
+
line = stream.readline()
|
|
343
|
+
if not line:
|
|
344
|
+
break
|
|
345
|
+
match = _SENTINEL_RE.match(line.strip())
|
|
346
|
+
with self._cond:
|
|
347
|
+
if match is not None:
|
|
348
|
+
if self._collecting and match.group(1) == self._pending_id:
|
|
349
|
+
self._exit_code = int(match.group(2))
|
|
350
|
+
self._collecting = False
|
|
351
|
+
self._cond.notify_all()
|
|
352
|
+
continue
|
|
353
|
+
if self._collecting and len(self._buffer) < MAX_OUTPUT:
|
|
354
|
+
self._buffer += line
|
|
355
|
+
if len(self._buffer) > MAX_OUTPUT:
|
|
356
|
+
self._truncated = True
|
|
357
|
+
elif self._collecting:
|
|
358
|
+
self._truncated = True
|
|
359
|
+
self._cond.notify_all()
|
|
360
|
+
except (OSError, ValueError): # pragma: no cover - stream torn down
|
|
361
|
+
pass
|
|
362
|
+
finally:
|
|
363
|
+
with self._cond:
|
|
364
|
+
self._eof = True
|
|
365
|
+
self._cond.notify_all()
|
|
366
|
+
|
|
367
|
+
def _ensure_alive(self) -> None:
|
|
368
|
+
if self.alive:
|
|
369
|
+
return
|
|
370
|
+
if self._proc is not None:
|
|
371
|
+
self.close()
|
|
372
|
+
self._start()
|
|
373
|
+
|
|
374
|
+
def run(self, command: str, timeout: float | None = None) -> tuple[int | None, str]:
|
|
375
|
+
"""Run one command; return ``(exit_code, output)``.
|
|
376
|
+
|
|
377
|
+
A timeout restarts the shell so a hung command can never wedge the
|
|
378
|
+
session; the returned exit code is ``None`` in that case and the output
|
|
379
|
+
explains what happened. Output is bounded to :data:`MAX_OUTPUT`.
|
|
380
|
+
"""
|
|
381
|
+
if not command.strip():
|
|
382
|
+
return 0, ""
|
|
383
|
+
self._ensure_alive()
|
|
384
|
+
proc = self._proc
|
|
385
|
+
assert proc is not None and proc.stdin is not None
|
|
386
|
+
|
|
387
|
+
command_id = uuid.uuid4().hex[:12]
|
|
388
|
+
payload = f"{command}\n{self._sentinel_command(command_id)}\n"
|
|
389
|
+
if timeout is not None:
|
|
390
|
+
effective_timeout = float(timeout)
|
|
391
|
+
elif self.timeout is not None:
|
|
392
|
+
effective_timeout = self.timeout
|
|
393
|
+
else:
|
|
394
|
+
effective_timeout = float(get_shell_config()["timeout"])
|
|
395
|
+
|
|
396
|
+
with self._cond:
|
|
397
|
+
self._buffer = ""
|
|
398
|
+
self._truncated = False
|
|
399
|
+
self._collecting = True
|
|
400
|
+
self._pending_id = command_id
|
|
401
|
+
self._exit_code = None
|
|
402
|
+
try:
|
|
403
|
+
_write_stdin(proc, payload)
|
|
404
|
+
except (BrokenPipeError, OSError) as exc:
|
|
405
|
+
self.close()
|
|
406
|
+
return None, f"Error: the shell exited before running the command ({exc})"
|
|
407
|
+
|
|
408
|
+
deadline = time.monotonic() + effective_timeout
|
|
409
|
+
timed_out = False
|
|
410
|
+
exited = False
|
|
411
|
+
with self._cond:
|
|
412
|
+
while self._exit_code is None:
|
|
413
|
+
if self._closed or self._eof or not self.alive:
|
|
414
|
+
exited = True
|
|
415
|
+
break
|
|
416
|
+
remaining = deadline - time.monotonic()
|
|
417
|
+
if remaining <= 0:
|
|
418
|
+
timed_out = True
|
|
419
|
+
break
|
|
420
|
+
self._cond.wait(min(remaining, _POLL_INTERVAL))
|
|
421
|
+
code = self._exit_code
|
|
422
|
+
output = self._buffer
|
|
423
|
+
truncated = self._truncated
|
|
424
|
+
self._buffer = ""
|
|
425
|
+
self._truncated = False
|
|
426
|
+
self._collecting = False
|
|
427
|
+
self._pending_id = None
|
|
428
|
+
self._exit_code = None
|
|
429
|
+
|
|
430
|
+
if timed_out:
|
|
431
|
+
self.close()
|
|
432
|
+
message = (
|
|
433
|
+
f"Error: command timed out after {effective_timeout:g}s; "
|
|
434
|
+
"the persistent shell was restarted"
|
|
435
|
+
)
|
|
436
|
+
combined = "\n".join(part for part in (output.rstrip("\n"), message) if part)
|
|
437
|
+
return None, _bounded_output(combined, truncated)
|
|
438
|
+
if exited:
|
|
439
|
+
self.close()
|
|
440
|
+
message = "Error: the shell exited unexpectedly"
|
|
441
|
+
combined = "\n".join(part for part in (output.rstrip("\n"), message) if part)
|
|
442
|
+
return None, _bounded_output(combined, truncated)
|
|
443
|
+
return code, _bounded_output(output.rstrip("\n"), truncated)
|
|
444
|
+
|
|
445
|
+
def close(self) -> None:
|
|
446
|
+
"""Shut the shell down cleanly, escalating to SIGKILL if needed."""
|
|
447
|
+
with self._cond:
|
|
448
|
+
self._closed = True
|
|
449
|
+
self._collecting = False
|
|
450
|
+
self._pending_id = None
|
|
451
|
+
self._exit_code = None
|
|
452
|
+
self._cond.notify_all()
|
|
453
|
+
proc = self._proc
|
|
454
|
+
self._proc = None
|
|
455
|
+
if proc is None:
|
|
456
|
+
return
|
|
457
|
+
try:
|
|
458
|
+
if proc.stdin is not None:
|
|
459
|
+
proc.stdin.close()
|
|
460
|
+
except OSError:
|
|
461
|
+
pass
|
|
462
|
+
_terminate_process(proc)
|
|
463
|
+
reader = self._reader
|
|
464
|
+
self._reader = None
|
|
465
|
+
if reader is not None and reader is not threading.current_thread():
|
|
466
|
+
reader.join(timeout=1.0)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@dataclass
|
|
470
|
+
class BackgroundProcess:
|
|
471
|
+
"""A detached command tracked by a :class:`ShellManager`."""
|
|
472
|
+
|
|
473
|
+
id: str
|
|
474
|
+
command: str
|
|
475
|
+
cwd: str
|
|
476
|
+
pid: int
|
|
477
|
+
output_path: Path
|
|
478
|
+
started_at: float = field(default_factory=time.time)
|
|
479
|
+
proc: subprocess.Popen[bytes] | None = field(default=None, repr=False)
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def alive(self) -> bool:
|
|
483
|
+
if self.proc is not None:
|
|
484
|
+
return self.proc.poll() is None
|
|
485
|
+
return _pid_alive(self.pid)
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def exit_code(self) -> int | None:
|
|
489
|
+
if self.proc is None:
|
|
490
|
+
return None
|
|
491
|
+
return self.proc.poll()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class ShellManager:
|
|
495
|
+
"""Owns one session's persistent shell and its background processes."""
|
|
496
|
+
|
|
497
|
+
def __init__(
|
|
498
|
+
self,
|
|
499
|
+
workspace: Path | str,
|
|
500
|
+
*,
|
|
501
|
+
persistent: bool | None = None,
|
|
502
|
+
timeout: float | None = None,
|
|
503
|
+
max_jobs: int | None = None,
|
|
504
|
+
) -> None:
|
|
505
|
+
cfg = get_shell_config()
|
|
506
|
+
self.workspace = Path(workspace)
|
|
507
|
+
self.persistent = bool(cfg["persistent"]) if persistent is None else persistent
|
|
508
|
+
self.timeout = float(cfg["timeout"]) if timeout is None else float(timeout)
|
|
509
|
+
self.max_jobs = int(cfg["max_jobs"]) if max_jobs is None else int(max_jobs)
|
|
510
|
+
self._shell: PersistentShell | None = None
|
|
511
|
+
self._jobs: dict[str, BackgroundProcess] = {}
|
|
512
|
+
self._lock = threading.Lock()
|
|
513
|
+
|
|
514
|
+
def shell(self) -> PersistentShell:
|
|
515
|
+
"""Return the session's persistent shell, starting it on first use."""
|
|
516
|
+
if not self.persistent:
|
|
517
|
+
raise ShellDisabledError(
|
|
518
|
+
"Persistent shell sessions are disabled "
|
|
519
|
+
"(enable with `config shell persistent on`)."
|
|
520
|
+
)
|
|
521
|
+
with self._lock:
|
|
522
|
+
if self._shell is None or not self._shell.alive:
|
|
523
|
+
self._shell = PersistentShell(self.workspace, timeout=self.timeout)
|
|
524
|
+
return self._shell
|
|
525
|
+
|
|
526
|
+
def run(self, command: str, timeout: float | None = None) -> tuple[int | None, str]:
|
|
527
|
+
"""Convenience wrapper around :meth:`PersistentShell.run`."""
|
|
528
|
+
return self.shell().run(command, timeout=timeout)
|
|
529
|
+
|
|
530
|
+
def start_background(self, command: str, cwd: str | Path | None = None) -> str:
|
|
531
|
+
"""Start a detached command, redirecting output to a per-job file."""
|
|
532
|
+
command = str(command).strip()
|
|
533
|
+
if not command:
|
|
534
|
+
raise ShellError("A background command is required.")
|
|
535
|
+
workdir = Path(cwd) if cwd is not None else self.workspace
|
|
536
|
+
with self._lock:
|
|
537
|
+
live = sum(1 for job in self._jobs.values() if job.alive)
|
|
538
|
+
if live >= self.max_jobs:
|
|
539
|
+
raise ShellJobLimitError(
|
|
540
|
+
f"Background job limit reached ({self.max_jobs}); kill a job "
|
|
541
|
+
"or raise `shell.max_jobs` in config."
|
|
542
|
+
)
|
|
543
|
+
job_id = uuid.uuid4().hex[:12]
|
|
544
|
+
output_path = _shell_jobs_dir() / f"{job_id}.log"
|
|
545
|
+
try:
|
|
546
|
+
output_file = output_path.open("wb")
|
|
547
|
+
except OSError as exc:
|
|
548
|
+
raise ShellError(f"Failed to create the job output file: {exc}") from exc
|
|
549
|
+
try:
|
|
550
|
+
remote_argv, _remote_warning = wrap_remote_command(
|
|
551
|
+
command, workspace=workdir
|
|
552
|
+
)
|
|
553
|
+
if remote_argv is not None:
|
|
554
|
+
argv = remote_argv
|
|
555
|
+
use_shell = False
|
|
556
|
+
else:
|
|
557
|
+
argv, _warning = wrap_command(command, workspace=workdir)
|
|
558
|
+
use_shell = os.name == "nt"
|
|
559
|
+
proc = subprocess.Popen(
|
|
560
|
+
command if use_shell else argv,
|
|
561
|
+
shell=use_shell,
|
|
562
|
+
cwd=str(workdir),
|
|
563
|
+
stdin=subprocess.DEVNULL,
|
|
564
|
+
stdout=output_file,
|
|
565
|
+
stderr=subprocess.STDOUT,
|
|
566
|
+
start_new_session=True,
|
|
567
|
+
)
|
|
568
|
+
except OSError as exc:
|
|
569
|
+
raise ShellError(f"Failed to start the background command: {exc}") from exc
|
|
570
|
+
finally:
|
|
571
|
+
output_file.close()
|
|
572
|
+
self._jobs[job_id] = BackgroundProcess(
|
|
573
|
+
id=job_id,
|
|
574
|
+
command=command,
|
|
575
|
+
cwd=str(workdir),
|
|
576
|
+
pid=proc.pid,
|
|
577
|
+
output_path=output_path,
|
|
578
|
+
proc=proc,
|
|
579
|
+
)
|
|
580
|
+
return job_id
|
|
581
|
+
|
|
582
|
+
def list_background(self) -> list[BackgroundProcess]:
|
|
583
|
+
"""Return tracked background jobs, oldest first."""
|
|
584
|
+
with self._lock:
|
|
585
|
+
return sorted(self._jobs.values(), key=lambda job: job.started_at)
|
|
586
|
+
|
|
587
|
+
def get_background(self, job_id: str) -> BackgroundProcess | None:
|
|
588
|
+
"""Return a tracked background job, or None when the id is unknown."""
|
|
589
|
+
with self._lock:
|
|
590
|
+
return self._jobs.get(job_id)
|
|
591
|
+
|
|
592
|
+
def output_background(self, job_id: str, max_bytes: int = MAX_OUTPUT) -> str | None:
|
|
593
|
+
"""Return a job's output (bounded), or None when the id is unknown."""
|
|
594
|
+
with self._lock:
|
|
595
|
+
job = self._jobs.get(job_id)
|
|
596
|
+
if job is None:
|
|
597
|
+
return None
|
|
598
|
+
try:
|
|
599
|
+
with job.output_path.open("rb") as handle:
|
|
600
|
+
data = handle.read(max_bytes + 1)
|
|
601
|
+
except OSError:
|
|
602
|
+
return ""
|
|
603
|
+
truncated = len(data) > max_bytes
|
|
604
|
+
if truncated:
|
|
605
|
+
data = data[:max_bytes]
|
|
606
|
+
text = data.decode("utf-8", errors="replace")
|
|
607
|
+
if truncated:
|
|
608
|
+
text += "\n... [output truncated]"
|
|
609
|
+
return text
|
|
610
|
+
|
|
611
|
+
def kill_background(self, job_id: str) -> bool:
|
|
612
|
+
"""Terminate a tracked background job; returns whether it existed."""
|
|
613
|
+
with self._lock:
|
|
614
|
+
job = self._jobs.pop(job_id, None)
|
|
615
|
+
if job is None:
|
|
616
|
+
return False
|
|
617
|
+
if job.proc is not None:
|
|
618
|
+
_terminate_process(job.proc)
|
|
619
|
+
else:
|
|
620
|
+
_kill_pid(job.pid)
|
|
621
|
+
return True
|
|
622
|
+
|
|
623
|
+
def close(self) -> None:
|
|
624
|
+
"""Close the persistent shell and kill every tracked background job."""
|
|
625
|
+
with self._lock:
|
|
626
|
+
shell = self._shell
|
|
627
|
+
self._shell = None
|
|
628
|
+
jobs = list(self._jobs.values())
|
|
629
|
+
self._jobs.clear()
|
|
630
|
+
if shell is not None:
|
|
631
|
+
shell.close()
|
|
632
|
+
for job in jobs:
|
|
633
|
+
if job.proc is not None:
|
|
634
|
+
_terminate_process(job.proc)
|
|
635
|
+
else:
|
|
636
|
+
_kill_pid(job.pid)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def get_shell(session: Session) -> ShellManager:
|
|
640
|
+
"""Return the session's shell manager, creating it on first use."""
|
|
641
|
+
manager = getattr(session, "shell", None)
|
|
642
|
+
if not isinstance(manager, ShellManager):
|
|
643
|
+
manager = ShellManager(session.workspace_root)
|
|
644
|
+
session.shell = manager
|
|
645
|
+
return manager
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def close_shell(session: Session) -> None:
|
|
649
|
+
"""Close and detach the session's shell manager; never raises."""
|
|
650
|
+
manager = getattr(session, "shell", None)
|
|
651
|
+
if manager is None:
|
|
652
|
+
return
|
|
653
|
+
try:
|
|
654
|
+
session.shell = None
|
|
655
|
+
except (AttributeError, TypeError): # pragma: no cover - defensive
|
|
656
|
+
pass
|
|
657
|
+
try:
|
|
658
|
+
manager.close()
|
|
659
|
+
except Exception: # pragma: no cover - cleanup must not raise
|
|
660
|
+
pass
|