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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. 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