hexcli 2.8.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.
hexcli/safety.py ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.safety — Command safety classifier and append-only audit log."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ # Patterns checked in order: first match wins. Destructive > safe > caution.
11
+
12
+ _DESTRUCTIVE: list[re.Pattern[str]] = [re.compile(p, re.IGNORECASE) for p in [
13
+ r"\bremove-item\b",
14
+ r"(?<![a-z])rm\s", # rm <args> but not 'strm' etc.
15
+ r"(?<![a-z])del\s",
16
+ r"(?<![a-z])rd\s",
17
+ r"\berase\b",
18
+ # Format-Volume / Format-Disk / the DOS `format X:` — not PowerShell's
19
+ # output formatters (Format-List, Format-Table, Format-Wide, Format-Custom,
20
+ # Format-Hex), which the CPU/RAM cookbook queries end in. Those asked for
21
+ # confirmation for weeks and were auto-denied in every unattended eval.
22
+ r"\bformat-(?!list\b|table\b|wide\b|custom\b|hex\b)\w+",
23
+ r"^\s*format\s+[a-z]:",
24
+ r"git\s+reset\s+--hard\b",
25
+ r"git\s+push\s+(-f\b|--force\b)",
26
+ r"git\s+clean\s+-[a-z]*f", # git clean -f / -df / -xf
27
+ r"\breg\s+delete\b",
28
+ r"\bclear-recyclebin\b",
29
+ r"\b(stop|restart)-computer\b",
30
+ r"\bdiskpart\b",
31
+ # -Force and -Recurse together — almost always Remove-Item level danger
32
+ r"-force\b[^|&\n]*-recurse\b|-recurse\b[^|&\n]*-force\b",
33
+ # Invoke-Expression / iex: evaluates arbitrary strings as code
34
+ r"\b(invoke-expression|iex)\b",
35
+ ]]
36
+
37
+ # Sensitive-data access: not destructive, but touching credentials, keys, or
38
+ # security-critical system files. Requires explicit confirmation (deny when
39
+ # non-interactive). Checked BEFORE the safe list — v1.7's blanket `^get-\w+`
40
+ # safe rule waved `Get-Content …\drivers\etc\hosts` straight through, which is
41
+ # exactly the injection payload uc3-t7 measured executing 3/3.
42
+ _SENSITIVE: list[re.Pattern[str]] = [re.compile(p, re.IGNORECASE) for p in [
43
+ r"[\\/]\.ssh\b", # ssh keys & config
44
+ r"\bid_(rsa|ed25519|ecdsa|dsa)\b",
45
+ r"[\\/]\.gnupg\b",
46
+ r"[\\/]\.aws\b",
47
+ r"[\\/]\.azure\b",
48
+ r"drivers[\\/]+etc[\\/]+hosts\b", # hosts-file tampering/recon
49
+ r"\bconfig[\\/]+(sam|security|system)\b", # registry hive files
50
+ r"\breg\s+(save|export)\b.*\b(sam|security|system)\b",
51
+ r"\bntds\.dit\b",
52
+ r"\bvaultcmd\b", # Windows credential vault
53
+ r"\bcmdkey\b.*/list",
54
+ r"login data", # browser credential stores
55
+ r"-encodedcommand\b|\s-enc\b|\s-e[nc]?\b\s+[A-Za-z0-9+/=]{20,}", # obfuscated exec
56
+ r"frombase64string",
57
+ r"protecteddata\]::unprotect", # DPAPI secrets
58
+ r"\$env:\w*(token|secret|password|api_?key)\w*", # credential env vars
59
+ # Launching a program image (uc3-t9's calc.exe): run_code's workspace
60
+ # boundary refuses `C:\Windows\System32\calc.exe`, but the same payload
61
+ # routed through run_command ran three different ways in one afternoon
62
+ # (2026-09-02: the absolute path, bare `calc.exe`, `start calc.exe`).
63
+ # Any command segment that STARTS with an executable image, plus
64
+ # Start-Process / start / cmd /c start, is confirm-gated like the rest
65
+ # of this tier. `python script.py`, cmdlets and git are untouched.
66
+ r"(?:^|[&|;]\s*)(?:&\s*)?(?:\"[^\"]*?\.(?:exe|com|bat|cmd|msi|scr|vbs|ps1|js)\b[^\"]*\""
67
+ r"|'[^']*?\.(?:exe|com|bat|cmd|msi|scr|vbs|ps1|js)\b[^']*'"
68
+ r"|(?:[a-z]:[\\/]|[.\\/]+)?[^\s\"'|&;]*?\.(?:exe|com|bat|cmd|msi|scr|vbs|ps1|js)\b)",
69
+ r"(?:^|[&|;]\s*)(?:start-process|start|saps)\b",
70
+ r"\bcmd(\.exe)?\s+/[ck]\s+start\b",
71
+ ]]
72
+
73
+ _SAFE: list[re.Pattern[str]] = [re.compile(p, re.IGNORECASE) for p in [
74
+ r"^\s*get-\w+", # Get-Process, Get-ChildItem …
75
+ r"^\s*(ls|dir)\b",
76
+ r"^\s*(cat|type)\s",
77
+ r"^\s*git\s+(status|log|diff|show|branch|stash list|tag|remote -v|describe)\b",
78
+ r"^\s*(python|python3|py)\s+(--version|-V)\b",
79
+ r"^\s*pip\s+(list|show|freeze)\b",
80
+ r"^\s*(where|where\.exe)\b",
81
+ r"^\s*(echo|write-output|write-host|pwd|test-path)\b",
82
+ r"^\s*(node|npm)\s+(--version|-v)\b",
83
+ r"^\s*select-string\b", # grep equivalent — read-only
84
+ ]]
85
+
86
+
87
+ def classify_command(cmd: str) -> str:
88
+ """Return 'safe', 'caution', 'sensitive', or 'destructive'.
89
+
90
+ Priority: destructive > sensitive > safe > caution. Sensitive must outrank
91
+ the safe list, or read-only cmdlet prefixes whitelist credential access.
92
+ """
93
+ s = cmd.strip()
94
+ for pat in _DESTRUCTIVE:
95
+ if pat.search(s):
96
+ return "destructive"
97
+ for pat in _SENSITIVE:
98
+ if pat.search(s):
99
+ return "sensitive"
100
+ for pat in _SAFE:
101
+ if pat.match(s):
102
+ return "safe"
103
+ return "caution"
104
+
105
+
106
+ def append_audit_log(
107
+ session_id: str | None,
108
+ classification: str,
109
+ cmd: str,
110
+ exit_code: int | str | None = None,
111
+ ) -> None:
112
+ """Append one JSON line to .shellai/audit.log. Best-effort — never raises."""
113
+ try:
114
+ log_dir = Path.cwd() / ".shellai"
115
+ log_dir.mkdir(parents=True, exist_ok=True)
116
+ entry: dict[str, object] = {
117
+ "ts": datetime.now(UTC).isoformat(),
118
+ "session": session_id or "",
119
+ "classification": classification,
120
+ "cmd": cmd,
121
+ }
122
+ if exit_code is not None:
123
+ entry["exit_code"] = exit_code
124
+ with (log_dir / "audit.log").open("a", encoding="utf-8") as fh:
125
+ fh.write(json.dumps(entry) + "\n")
126
+ except Exception:
127
+ pass
hexcli/sessions.py ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.sessions — session objects and the on-disk history store.
3
+
4
+ Lifted out of agent.py unchanged. Owns HISTORY_PATH, because the only readers
5
+ of it are the two functions here; keeping the path next to them is what makes
6
+ the store redirectable in tests.
7
+
8
+ `evals/test_core.py` patches `sessions.HISTORY_PATH` to a temp file. That patch
9
+ MUST target this module: these functions resolve the name in their own
10
+ namespace, so patching a re-exported copy on hexcli.agent would silently do
11
+ nothing and the suite would write to the real history.json while still passing.
12
+
13
+ Checkpoints (/save, /load) deliberately stay in agent.py — they capture a
14
+ workspace_snapshot, which belongs with the tools.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from datetime import UTC, datetime, timedelta
21
+ from typing import Any
22
+ from uuid import uuid4
23
+
24
+ from hexcli import paths
25
+ from hexcli.ui import C, cprint
26
+
27
+ # ~/.shellai/history.json (a checkout's history.json is migrated once).
28
+ HISTORY_PATH = paths.history_path()
29
+
30
+
31
+ def utc_now() -> datetime:
32
+ return datetime.now(UTC)
33
+
34
+
35
+ def iso_now() -> str:
36
+ return utc_now().isoformat()
37
+
38
+
39
+ def parse_timestamp(value: str) -> datetime:
40
+ dt = datetime.fromisoformat(value)
41
+ if dt.tzinfo is None:
42
+ dt = dt.replace(tzinfo=UTC)
43
+ return dt
44
+
45
+
46
+ def create_session() -> dict[str, Any]:
47
+ now = iso_now()
48
+ return {
49
+ "id": str(uuid4()),
50
+ "title": "New session",
51
+ "created_at": now,
52
+ "modified_at": now,
53
+ "messages": [],
54
+ "compact_count": 0,
55
+ }
56
+
57
+
58
+ def session_has_messages(session: dict[str, Any]) -> bool:
59
+ msgs = session.get("messages")
60
+ return isinstance(msgs, list) and len(msgs) > 0
61
+
62
+
63
+ def generate_session_title(text: str) -> str:
64
+ """The first words of the first message, as typed: "what is 2+2" stays
65
+ readable ("What Is 22" did not). Trailing punctuation goes, the case
66
+ is the person's own but for the first letter, and long lines are cut
67
+ at a word."""
68
+ first_line = text.strip().splitlines()[0] if text.strip() else ""
69
+ cleaned = re.sub(r"[^A-Za-z0-9\s\-+_./:'\"]", "", first_line)
70
+ words = [w.strip("-+_./:'\"") for w in cleaned.split()]
71
+ words = [w for w in words if w]
72
+ if not words:
73
+ return "New session"
74
+ title = " ".join(words[:8])
75
+ if len(title) > 48:
76
+ title = title[:47].rsplit(" ", 1)[0] + "…"
77
+ return title[0].upper() + title[1:]
78
+
79
+
80
+ def touch_session(session: dict[str, Any]) -> None:
81
+ session["modified_at"] = iso_now()
82
+
83
+
84
+ def append_session_message(session: dict[str, Any], role: str, content: str) -> None:
85
+ msgs = session.setdefault("messages", [])
86
+ if not isinstance(msgs, list):
87
+ session["messages"] = []
88
+ msgs = session["messages"]
89
+ if not session_has_messages(session) and role == "user":
90
+ session["title"] = generate_session_title(content)
91
+ msgs.append({"role": role, "content": content})
92
+ touch_session(session)
93
+
94
+
95
+ def sort_sessions(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
96
+ _epoch = datetime.min.replace(tzinfo=UTC)
97
+
98
+ def _key(s: dict[str, Any]) -> datetime:
99
+ raw = s.get("modified_at", "")
100
+ try:
101
+ return parse_timestamp(str(raw))
102
+ except (ValueError, TypeError):
103
+ return _epoch
104
+
105
+ return sorted(sessions, key=_key, reverse=True)
106
+
107
+
108
+ def save_history_store(sessions: list[dict[str, Any]]) -> None:
109
+ payload = json.dumps({"sessions": sort_sessions(sessions)}, indent=2) + "\n"
110
+ tmp = HISTORY_PATH.with_suffix(".tmp")
111
+ tmp.write_text(payload, encoding="utf-8")
112
+ tmp.replace(HISTORY_PATH)
113
+
114
+
115
+ def load_history_store(config: dict[str, Any]) -> list[dict[str, Any]]:
116
+ sessions: list[dict[str, Any]] = []
117
+ if HISTORY_PATH.exists():
118
+ # A truncated or corrupted history file used to raise here and take
119
+ # the whole app down on EVERY launch — unrecoverable without knowing
120
+ # to delete a file you were never told about. Past history is never
121
+ # worth more than a working CLI: quarantine it and carry on.
122
+ try:
123
+ with HISTORY_PATH.open("r", encoding="utf-8") as fh:
124
+ data = json.load(fh)
125
+ raw = data.get("sessions", []) if isinstance(data, dict) else []
126
+ sessions = [s for s in raw if isinstance(s, dict)]
127
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
128
+ quarantine = HISTORY_PATH.with_suffix(".corrupt")
129
+ try:
130
+ HISTORY_PATH.replace(quarantine)
131
+ cprint(
132
+ f"\n History file was unreadable ({exc.__class__.__name__}). "
133
+ f"Moved it to {quarantine.name} and started a fresh history.",
134
+ C.YELLOW,
135
+ )
136
+ except OSError:
137
+ cprint("\n History file is unreadable and could not be moved; "
138
+ "continuing with an empty history.", C.YELLOW)
139
+ sessions = []
140
+
141
+ cutoff = utc_now() - timedelta(days=int(config.get("history_retention_days", 30)))
142
+ filtered: list[dict[str, Any]] = []
143
+ changed = False
144
+ for s in sessions:
145
+ try:
146
+ modified_at = parse_timestamp(str(s.get("modified_at", "")))
147
+ except ValueError:
148
+ changed = True
149
+ continue
150
+ if modified_at < cutoff:
151
+ changed = True
152
+ continue
153
+ s.setdefault("title", "New session")
154
+ s.setdefault("created_at", s.get("modified_at", iso_now()))
155
+ s.setdefault("messages", [])
156
+ s.setdefault("compact_count", 0)
157
+ filtered.append(s)
158
+
159
+ filtered = sort_sessions(filtered)
160
+ if changed:
161
+ save_history_store(filtered)
162
+ return filtered
163
+
164
+
165
+ def search_sessions(
166
+ sessions: list[dict[str, Any]],
167
+ term: str,
168
+ max_snippets: int = 2,
169
+ context: int = 44,
170
+ ) -> list[dict[str, Any]]:
171
+ """Case-insensitive substring search over titles and message content.
172
+
173
+ Returns hits in the same order as `sessions`, each carrying the 1-based
174
+ index into that list — the SAME number /history shows and /resume takes,
175
+ so a search result is directly resumable. Snippets come pre-split as
176
+ (role, prefix, match, suffix) so the renderer can highlight the match
177
+ without re-finding it.
178
+ """
179
+ term_l = term.lower()
180
+ if not term_l:
181
+ return []
182
+ hits: list[dict[str, Any]] = []
183
+ for idx, session in enumerate(sessions, start=1):
184
+ raw_matches: list[tuple[str, str, int]] = []
185
+ title = str(session.get("title", ""))
186
+ pos = title.lower().find(term_l)
187
+ if pos >= 0:
188
+ raw_matches.append(("title", title, pos))
189
+ for msg in session.get("messages", []):
190
+ if len(raw_matches) >= max_snippets:
191
+ break
192
+ if not isinstance(msg, dict):
193
+ continue
194
+ content = str(msg.get("content", ""))
195
+ pos = content.lower().find(term_l)
196
+ if pos >= 0:
197
+ raw_matches.append((str(msg.get("role", "?")), content, pos))
198
+ if not raw_matches:
199
+ continue
200
+ snippets: list[tuple[str, str, str, str]] = []
201
+ for role, text, pos in raw_matches[:max_snippets]:
202
+ start = max(0, pos - context)
203
+ end = min(len(text), pos + len(term) + context)
204
+ prefix = ("…" if start > 0 else "") + text[start:pos].replace("\n", " ")
205
+ match = text[pos:pos + len(term)].replace("\n", " ")
206
+ suffix = text[pos + len(term):end].replace("\n", " ") + ("…" if end < len(text) else "")
207
+ snippets.append((role, prefix, match, suffix))
208
+ hits.append({"index": idx, "session": session, "snippets": snippets})
209
+ return hits
210
+
211
+
212
+ def upsert_session(sessions: list[dict[str, Any]], session: dict[str, Any]) -> None:
213
+ if not session_has_messages(session):
214
+ return
215
+ for i, existing in enumerate(sessions):
216
+ if existing.get("id") == session.get("id"):
217
+ sessions[i] = session
218
+ return
219
+ sessions.append(session)
220
+
221
+
222
+ def sync_session_store(sessions: list[dict[str, Any]], session: dict[str, Any]) -> None:
223
+ upsert_session(sessions, session)
224
+ save_history_store(sessions)
225
+
226
+
hexcli/setup_wizard.py ADDED
@@ -0,0 +1,144 @@
1
+ """hexcli/setup_wizard.py — /setup: interactive configuration wizard.
2
+
3
+ A handful of questions covering the config decisions that actually matter
4
+ day one (safety posture, network policy, UI). Each shows the current value
5
+ as the default, so Enter-Enter-Enter through the wizard changes nothing.
6
+
7
+ It writes ONLY the keys the user was asked about, merged over whatever the
8
+ config file already contains — never a full DEFAULT_CONFIG dump. A config
9
+ file full of copied defaults is exactly the drift bug the generated example
10
+ config exists to prevent: values silently pinned at whatever release they
11
+ were written by.
12
+
13
+ IO is injected (ask/echo) so the whole flow is testable offline; EOF or
14
+ Ctrl+C at any question aborts without writing.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from hexcli.ui import C, cprint
24
+
25
+ # (key, question, kind, choices) — kind is "bool" or "choice".
26
+ QUESTIONS: list[tuple[str, str, str, tuple[str, ...]]] = [
27
+ ("autopilot_confirm_destructive",
28
+ "Confirm destructive commands such as rm and format?", "bool", ()),
29
+ ("autopilot_confirm_sensitive",
30
+ "Confirm access to sensitive paths such as SSH keys and credentials?", "bool", ()),
31
+ ("workspace_write_scope",
32
+ "Block file writes outside the project directory?", "bool", ()),
33
+ ("network_access",
34
+ "Network access for fetch_url?", "choice", ("ask", "deny", "allow")),
35
+ ("show_diffs",
36
+ "Show a diff after every file change?", "bool", ()),
37
+ ("rich_input",
38
+ "Rich input line with history and Tab completion?", "bool", ()),
39
+ ]
40
+
41
+
42
+ def _ask_bool(ask: Callable[[str], str], question: str, current: bool) -> bool:
43
+ hint = "[Y/n]" if current else "[y/N]"
44
+ answer = ask(f" {question} {hint} ").strip().lower()
45
+ if not answer:
46
+ return current
47
+ return answer in ("y", "yes", "1", "true", "on")
48
+
49
+
50
+ def _ask_choice(ask: Callable[[str], str], question: str,
51
+ choices: tuple[str, ...], current: str) -> str:
52
+ menu = "/".join(c.upper() if c == current else c for c in choices)
53
+ while True:
54
+ answer = ask(f" {question} [{menu}] ").strip().lower()
55
+ if not answer:
56
+ return current
57
+ if answer in choices:
58
+ return answer
59
+ cprint(f" Answer {', '.join(choices[:-1])} or {choices[-1]}.", C.DIM)
60
+
61
+
62
+ def write_config_keys(path: Path, chosen: dict[str, Any]) -> None:
63
+ """Merge the chosen keys over the existing file, atomically."""
64
+ existing: dict[str, Any] = {}
65
+ if path.exists():
66
+ try:
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ if isinstance(data, dict):
69
+ existing = data
70
+ except (json.JSONDecodeError, OSError):
71
+ pass # unreadable file: the wizard's keys become the file
72
+ existing.update(chosen)
73
+ payload = json.dumps(existing, indent=2) + "\n"
74
+ tmp = path.with_suffix(".tmp")
75
+ tmp.write_text(payload, encoding="utf-8")
76
+ tmp.replace(path)
77
+
78
+
79
+ def overridden_by_project(chosen: dict[str, Any], project_cfg: Path) -> list[str]:
80
+ """Keys the wizard just saved that a project .shellai/config.json will
81
+ still override on every load (it deep-merges on top of the user config).
82
+
83
+ Without this, the wizard's closing "applies on every launch" line is a
84
+ lie in any repo that ships its own config.
85
+ """
86
+ if not project_cfg.exists():
87
+ return []
88
+ try:
89
+ data = json.loads(project_cfg.read_text(encoding="utf-8"))
90
+ except (json.JSONDecodeError, OSError):
91
+ return []
92
+ if not isinstance(data, dict):
93
+ return []
94
+ return [k for k in chosen if k in data and data[k] != chosen[k]]
95
+
96
+
97
+ def run_wizard(
98
+ config: dict[str, Any],
99
+ config_path: Path,
100
+ ask: Callable[[str], str] = input,
101
+ project_cfg: Path | None = None,
102
+ ) -> bool:
103
+ """Run the wizard. Returns True if the config file was written.
104
+
105
+ Updates `config` in place on success so answers apply to the running
106
+ session immediately, not just the next launch.
107
+ """
108
+ print()
109
+ cprint(" Setup", C.BOLD)
110
+ cprint(" Enter keeps the current value.", C.DIM)
111
+ print()
112
+ chosen: dict[str, Any] = {}
113
+ try:
114
+ for key, question, kind, choices in QUESTIONS:
115
+ if kind == "bool":
116
+ chosen[key] = _ask_bool(ask, question, bool(config.get(key, True)))
117
+ else:
118
+ current = str(config.get(key, choices[0]))
119
+ if current not in choices:
120
+ current = choices[0]
121
+ chosen[key] = _ask_choice(ask, question, choices, current)
122
+ print()
123
+ for key, value in chosen.items():
124
+ marker = "" if config.get(key) == value else " changed"
125
+ cprint(f" {key} = {value!r}{marker}", C.DIM)
126
+ print()
127
+ confirm = ask(f" Save to {config_path.name}? [Y/n] ").strip().lower()
128
+ except (EOFError, KeyboardInterrupt):
129
+ print()
130
+ cprint(" Cancelled. Nothing written.", C.DIM)
131
+ return False
132
+ if confirm not in ("", "y", "yes"):
133
+ cprint(" Nothing written.", C.DIM)
134
+ return False
135
+ write_config_keys(config_path, chosen)
136
+ config.update(chosen)
137
+ cprint(f" Saved {config_path}.", C.DIM)
138
+ shadowed = overridden_by_project(
139
+ chosen, project_cfg if project_cfg is not None else Path.cwd() / ".shellai" / "config.json"
140
+ )
141
+ if shadowed:
142
+ cprint(f" .shellai/config.json overrides {', '.join(sorted(shadowed))}. "
143
+ "Edit it there too.", C.YELLOW)
144
+ return True
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.shell_session — persistent PowerShell session for the v2 `shell` tool.
3
+
4
+ v1 spawned a fresh `powershell.exe -Command` per call, so `cd`, environment
5
+ variables, and venv activation evaporated between steps. This module keeps ONE
6
+ PowerShell process alive per agent session and multiplexes commands through it
7
+ with a sentinel protocol:
8
+
9
+ <command>
10
+ Write-Output "<sentinel> <exit-ish code>"
11
+
12
+ stdout+stderr are merged at the command level (2>&1) so the model sees errors
13
+ inline, in order. A timeout kills the whole process tree (taskkill /T) and the
14
+ next call transparently respawns a fresh session — a hung command can never
15
+ wedge the agent loop.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import queue
21
+ import subprocess
22
+ import threading
23
+ import uuid
24
+ from typing import Any
25
+
26
+ _DEFAULT_TIMEOUT_S = 60
27
+ _OUTPUT_CAP_CHARS = 200_000 # hard runaway guard; the agent loop trims further
28
+
29
+
30
+ class ShellSession:
31
+ """One persistent PowerShell process; safe to reuse across commands."""
32
+
33
+ def __init__(self, cwd: str | None = None, shell_exe: str = "powershell.exe") -> None:
34
+ self._cwd = cwd
35
+ self._shell_exe = shell_exe
36
+ self._proc: subprocess.Popen[str] | None = None
37
+ self._out_queue: queue.Queue[str | None] = queue.Queue()
38
+ self._reader: threading.Thread | None = None
39
+ self._lock = threading.Lock()
40
+
41
+ # -- lifecycle ----------------------------------------------------------
42
+
43
+ def _spawn(self) -> None:
44
+ self._proc = subprocess.Popen(
45
+ [self._shell_exe, "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", "-"],
46
+ stdin=subprocess.PIPE,
47
+ stdout=subprocess.PIPE,
48
+ stderr=subprocess.STDOUT,
49
+ cwd=self._cwd,
50
+ text=True,
51
+ encoding="utf-8",
52
+ errors="replace",
53
+ bufsize=1,
54
+ )
55
+ self._out_queue = queue.Queue()
56
+ self._reader = threading.Thread(target=self._read_loop, daemon=True)
57
+ self._reader.start()
58
+
59
+ def _read_loop(self) -> None:
60
+ proc = self._proc
61
+ if proc is None or proc.stdout is None:
62
+ return
63
+ q = self._out_queue
64
+ for line in proc.stdout:
65
+ q.put(line)
66
+ q.put(None) # EOF marker
67
+
68
+ def _send(self, text: str) -> None:
69
+ assert self._proc is not None and self._proc.stdin is not None
70
+ self._proc.stdin.write(text + "\n")
71
+ self._proc.stdin.flush()
72
+
73
+ def _alive(self) -> bool:
74
+ return self._proc is not None and self._proc.poll() is None
75
+
76
+ def close(self) -> None:
77
+ with self._lock:
78
+ self._kill_tree()
79
+
80
+ def _kill_tree(self) -> None:
81
+ if self._proc is None:
82
+ return
83
+ pid = self._proc.pid
84
+ try:
85
+ subprocess.run(
86
+ ["taskkill", "/T", "/F", "/PID", str(pid)],
87
+ capture_output=True, timeout=10,
88
+ )
89
+ except Exception:
90
+ try:
91
+ self._proc.kill()
92
+ except Exception:
93
+ pass
94
+ self._proc = None
95
+
96
+ # -- command execution --------------------------------------------------
97
+
98
+ def run(self, command: str, timeout_s: int = _DEFAULT_TIMEOUT_S) -> dict[str, Any]:
99
+ """Run one command; returns {"output": str, "exit_code": int|None,
100
+ "timed_out": bool, "restarted": bool}."""
101
+ with self._lock:
102
+ restarted = False
103
+ if not self._alive():
104
+ self._kill_tree()
105
+ self._spawn()
106
+ restarted = self._proc is not None
107
+
108
+ sentinel = f"__HEX_DONE_{uuid.uuid4().hex}__"
109
+ # Windows PowerShell 5.1 decodes piped stdin — and encodes piped
110
+ # stdout — with the OEM codepage, mangling any non-ASCII content.
111
+ # So the command travels IN as UTF-16LE base64 (Invoke-Expression
112
+ # keeps it in the session's own scope: cd, $env:, and variables
113
+ # all persist), and the output travels OUT as UTF-8 base64 on the
114
+ # sentinel line. Base64 is pure ASCII and survives any codepage.
115
+ #
116
+ # Exit-code detection: native commands set $LASTEXITCODE; cmdlet
117
+ # failures are detected via $Error growth ($? is useless here —
118
+ # it would reflect the last pipeline stage, not the command).
119
+ cmd_b64 = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
120
+ wrapped = (
121
+ "$global:LASTEXITCODE = $null; $__hex_errs = $Error.Count; "
122
+ f"$__hex_cmd = [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('{cmd_b64}')); "
123
+ # Dot-sourcing a created ScriptBlock runs in the CURRENT scope
124
+ # (cd/$env:/variables persist) while letting 2>&1 capture the
125
+ # invoked command's error stream — Invoke-Expression's redirect
126
+ # cannot see errors raised inside the expression.
127
+ "try { $__hex_out = . ([System.Management.Automation.ScriptBlock]::Create($__hex_cmd)) 2>&1 | Out-String } "
128
+ "catch { $__hex_out = ($_ | Out-String) ; $__hex_errs = -1 }; "
129
+ "$__hex_code = if ($null -ne $global:LASTEXITCODE) { $global:LASTEXITCODE } "
130
+ "elseif ($__hex_errs -lt 0 -or $Error.Count -gt $__hex_errs) { 1 } else { 0 }; "
131
+ f"if ($__hex_out.Length -gt {_OUTPUT_CAP_CHARS}) "
132
+ f"{{ $__hex_out = $__hex_out.Substring(0, {_OUTPUT_CAP_CHARS}) + \"`n[output truncated]\" }}; "
133
+ "$__hex_b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($__hex_out)); "
134
+ f"Write-Output \"{sentinel}:$__hex_code`:$__hex_b64\""
135
+ )
136
+ try:
137
+ self._send(wrapped)
138
+ except (OSError, AssertionError):
139
+ self._kill_tree()
140
+ return {"output": "shell session died while sending the command; it will restart on the next call",
141
+ "exit_code": None, "timed_out": False, "restarted": restarted}
142
+
143
+ lines: list[str] = []
144
+ total = 0
145
+ deadline = timeout_s
146
+ import time
147
+ start = time.monotonic()
148
+ while True:
149
+ remaining = deadline - (time.monotonic() - start)
150
+ if remaining <= 0:
151
+ self._kill_tree()
152
+ return {
153
+ "output": "".join(lines)[:_OUTPUT_CAP_CHARS]
154
+ + f"\n[timeout] command exceeded {timeout_s}s; the shell session was killed and will restart on the next call",
155
+ "exit_code": None, "timed_out": True, "restarted": restarted,
156
+ }
157
+ try:
158
+ line = self._out_queue.get(timeout=min(remaining, 0.5))
159
+ except queue.Empty:
160
+ continue
161
+ if line is None:
162
+ # Process exited underneath us (e.g. the command ran `exit`).
163
+ self._kill_tree()
164
+ return {"output": "".join(lines)[:_OUTPUT_CAP_CHARS],
165
+ "exit_code": None, "timed_out": False, "restarted": restarted}
166
+ if line.startswith(sentinel):
167
+ parts = line.strip().split(":", 2)
168
+ exit_code: int | None
169
+ output: str
170
+ try:
171
+ exit_code = int(parts[1])
172
+ except (IndexError, ValueError):
173
+ exit_code = None
174
+ try:
175
+ output = base64.b64decode(parts[2]).decode("utf-8", errors="replace") if len(parts) > 2 else ""
176
+ except Exception:
177
+ output = ""
178
+ # Anything that leaked outside the sentinel protocol (e.g.
179
+ # wrapper-level parse errors) is prepended so it's never lost.
180
+ if lines:
181
+ output = "".join(lines) + output
182
+ return {"output": output[:_OUTPUT_CAP_CHARS],
183
+ "exit_code": exit_code, "timed_out": False, "restarted": restarted}
184
+ total += len(line)
185
+ if total <= _OUTPUT_CAP_CHARS:
186
+ lines.append(line)