strix-agent 0.1.1__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 (99) hide show
  1. strix/__init__.py +0 -0
  2. strix/agents/StrixAgent/__init__.py +4 -0
  3. strix/agents/StrixAgent/strix_agent.py +60 -0
  4. strix/agents/StrixAgent/system_prompt.jinja +504 -0
  5. strix/agents/__init__.py +10 -0
  6. strix/agents/base_agent.py +394 -0
  7. strix/agents/state.py +139 -0
  8. strix/cli/__init__.py +4 -0
  9. strix/cli/app.py +1124 -0
  10. strix/cli/assets/cli.tcss +680 -0
  11. strix/cli/main.py +542 -0
  12. strix/cli/tool_components/__init__.py +39 -0
  13. strix/cli/tool_components/agents_graph_renderer.py +129 -0
  14. strix/cli/tool_components/base_renderer.py +61 -0
  15. strix/cli/tool_components/browser_renderer.py +107 -0
  16. strix/cli/tool_components/file_edit_renderer.py +95 -0
  17. strix/cli/tool_components/finish_renderer.py +32 -0
  18. strix/cli/tool_components/notes_renderer.py +108 -0
  19. strix/cli/tool_components/proxy_renderer.py +255 -0
  20. strix/cli/tool_components/python_renderer.py +34 -0
  21. strix/cli/tool_components/registry.py +72 -0
  22. strix/cli/tool_components/reporting_renderer.py +53 -0
  23. strix/cli/tool_components/scan_info_renderer.py +58 -0
  24. strix/cli/tool_components/terminal_renderer.py +99 -0
  25. strix/cli/tool_components/thinking_renderer.py +29 -0
  26. strix/cli/tool_components/user_message_renderer.py +43 -0
  27. strix/cli/tool_components/web_search_renderer.py +28 -0
  28. strix/cli/tracer.py +308 -0
  29. strix/llm/__init__.py +14 -0
  30. strix/llm/config.py +19 -0
  31. strix/llm/llm.py +310 -0
  32. strix/llm/memory_compressor.py +206 -0
  33. strix/llm/request_queue.py +63 -0
  34. strix/llm/utils.py +84 -0
  35. strix/prompts/__init__.py +113 -0
  36. strix/prompts/coordination/root_agent.jinja +41 -0
  37. strix/prompts/vulnerabilities/authentication_jwt.jinja +129 -0
  38. strix/prompts/vulnerabilities/business_logic.jinja +143 -0
  39. strix/prompts/vulnerabilities/csrf.jinja +168 -0
  40. strix/prompts/vulnerabilities/idor.jinja +164 -0
  41. strix/prompts/vulnerabilities/race_conditions.jinja +194 -0
  42. strix/prompts/vulnerabilities/rce.jinja +222 -0
  43. strix/prompts/vulnerabilities/sql_injection.jinja +216 -0
  44. strix/prompts/vulnerabilities/ssrf.jinja +168 -0
  45. strix/prompts/vulnerabilities/xss.jinja +221 -0
  46. strix/prompts/vulnerabilities/xxe.jinja +276 -0
  47. strix/runtime/__init__.py +19 -0
  48. strix/runtime/docker_runtime.py +298 -0
  49. strix/runtime/runtime.py +25 -0
  50. strix/runtime/tool_server.py +97 -0
  51. strix/tools/__init__.py +64 -0
  52. strix/tools/agents_graph/__init__.py +16 -0
  53. strix/tools/agents_graph/agents_graph_actions.py +610 -0
  54. strix/tools/agents_graph/agents_graph_actions_schema.xml +223 -0
  55. strix/tools/argument_parser.py +120 -0
  56. strix/tools/browser/__init__.py +4 -0
  57. strix/tools/browser/browser_actions.py +236 -0
  58. strix/tools/browser/browser_actions_schema.xml +183 -0
  59. strix/tools/browser/browser_instance.py +533 -0
  60. strix/tools/browser/tab_manager.py +342 -0
  61. strix/tools/executor.py +302 -0
  62. strix/tools/file_edit/__init__.py +4 -0
  63. strix/tools/file_edit/file_edit_actions.py +141 -0
  64. strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
  65. strix/tools/finish/__init__.py +4 -0
  66. strix/tools/finish/finish_actions.py +167 -0
  67. strix/tools/finish/finish_actions_schema.xml +45 -0
  68. strix/tools/notes/__init__.py +14 -0
  69. strix/tools/notes/notes_actions.py +191 -0
  70. strix/tools/notes/notes_actions_schema.xml +150 -0
  71. strix/tools/proxy/__init__.py +20 -0
  72. strix/tools/proxy/proxy_actions.py +101 -0
  73. strix/tools/proxy/proxy_actions_schema.xml +267 -0
  74. strix/tools/proxy/proxy_manager.py +785 -0
  75. strix/tools/python/__init__.py +4 -0
  76. strix/tools/python/python_actions.py +47 -0
  77. strix/tools/python/python_actions_schema.xml +131 -0
  78. strix/tools/python/python_instance.py +172 -0
  79. strix/tools/python/python_manager.py +131 -0
  80. strix/tools/registry.py +196 -0
  81. strix/tools/reporting/__init__.py +6 -0
  82. strix/tools/reporting/reporting_actions.py +63 -0
  83. strix/tools/reporting/reporting_actions_schema.xml +30 -0
  84. strix/tools/terminal/__init__.py +4 -0
  85. strix/tools/terminal/terminal_actions.py +53 -0
  86. strix/tools/terminal/terminal_actions_schema.xml +114 -0
  87. strix/tools/terminal/terminal_instance.py +231 -0
  88. strix/tools/terminal/terminal_manager.py +191 -0
  89. strix/tools/thinking/__init__.py +4 -0
  90. strix/tools/thinking/thinking_actions.py +18 -0
  91. strix/tools/thinking/thinking_actions_schema.xml +52 -0
  92. strix/tools/web_search/__init__.py +4 -0
  93. strix/tools/web_search/web_search_actions.py +80 -0
  94. strix/tools/web_search/web_search_actions_schema.xml +83 -0
  95. strix_agent-0.1.1.dist-info/LICENSE +201 -0
  96. strix_agent-0.1.1.dist-info/METADATA +200 -0
  97. strix_agent-0.1.1.dist-info/RECORD +99 -0
  98. strix_agent-0.1.1.dist-info/WHEEL +4 -0
  99. strix_agent-0.1.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,63 @@
1
+ from typing import Any
2
+
3
+ from strix.tools.registry import register_tool
4
+
5
+
6
+ @register_tool(sandbox_execution=False)
7
+ def create_vulnerability_report(
8
+ title: str,
9
+ content: str,
10
+ severity: str,
11
+ ) -> dict[str, Any]:
12
+ validation_error = None
13
+ if not title or not title.strip():
14
+ validation_error = "Title cannot be empty"
15
+ elif not content or not content.strip():
16
+ validation_error = "Content cannot be empty"
17
+ elif not severity or not severity.strip():
18
+ validation_error = "Severity cannot be empty"
19
+ else:
20
+ valid_severities = ["critical", "high", "medium", "low", "info"]
21
+ if severity.lower() not in valid_severities:
22
+ validation_error = (
23
+ f"Invalid severity '{severity}'. Must be one of: {', '.join(valid_severities)}"
24
+ )
25
+
26
+ if validation_error:
27
+ return {"success": False, "message": validation_error}
28
+
29
+ try:
30
+ from strix.cli.tracer import get_global_tracer
31
+
32
+ tracer = get_global_tracer()
33
+ if tracer:
34
+ report_id = tracer.add_vulnerability_report(
35
+ title=title,
36
+ content=content,
37
+ severity=severity,
38
+ )
39
+
40
+ return {
41
+ "success": True,
42
+ "message": f"Vulnerability report '{title}' created successfully",
43
+ "report_id": report_id,
44
+ "severity": severity.lower(),
45
+ }
46
+ import logging
47
+
48
+ logging.warning("Global tracer not available - vulnerability report not stored")
49
+
50
+ return { # noqa: TRY300
51
+ "success": True,
52
+ "message": f"Vulnerability report '{title}' created successfully (not persisted)",
53
+ "warning": "Report could not be persisted - tracer unavailable",
54
+ }
55
+
56
+ except ImportError:
57
+ return {
58
+ "success": True,
59
+ "message": f"Vulnerability report '{title}' created successfully (not persisted)",
60
+ "warning": "Report could not be persisted - tracer module unavailable",
61
+ }
62
+ except (ValueError, TypeError) as e:
63
+ return {"success": False, "message": f"Failed to create vulnerability report: {e!s}"}
@@ -0,0 +1,30 @@
1
+ <tools>
2
+ <tool name="create_vulnerability_report">
3
+ <description>Create a vulnerability report for a discovered security issue.
4
+
5
+ Use this tool to document a specific verified security vulnerability.
6
+ Put ALL details in the content field - affected URLs, parameters, proof of concept, remediation steps, CVE references, CVSS scores, technical details, impact assessment, etc.
7
+
8
+ DO NOT USE:
9
+ - For general security observations without specific vulnerabilities
10
+ - When you don't have concrete vulnerability details
11
+ - When you don't have a proof of concept, or still not 100% sure if it's a vulnerability
12
+ - For tracking multiple vulnerabilities (create separate reports)
13
+ - For reporting multiple vulnerabilities at once. Use a separate create_vulnerability_report for each vulnerability.
14
+ </description>
15
+ <parameters>
16
+ <parameter name="title" type="string" required="true">
17
+ <description>Clear, concise title of the vulnerability</description>
18
+ </parameter>
19
+ <parameter name="content" type="string" required="true">
20
+ <description>Complete vulnerability details including affected URLs, technical details, impact, proof of concept, remediation steps, and any relevant references. Be comprehensive and include everything relevant.</description>
21
+ </parameter>
22
+ <parameter name="severity" type="string" required="true">
23
+ <description>Severity level: critical, high, medium, low, or info</description>
24
+ </parameter>
25
+ </parameters>
26
+ <returns type="Dict[str, Any]">
27
+ <description>Response containing success status and message</description>
28
+ </returns>
29
+ </tool>
30
+ </tools>
@@ -0,0 +1,4 @@
1
+ from .terminal_actions import terminal_action
2
+
3
+
4
+ __all__ = ["terminal_action"]
@@ -0,0 +1,53 @@
1
+ from typing import Any, Literal
2
+
3
+ from strix.tools.registry import register_tool
4
+
5
+ from .terminal_manager import get_terminal_manager
6
+
7
+
8
+ TerminalAction = Literal["new_terminal", "send_input", "wait", "close"]
9
+
10
+
11
+ @register_tool
12
+ def terminal_action(
13
+ action: TerminalAction,
14
+ inputs: list[str] | None = None,
15
+ time: float | None = None,
16
+ terminal_id: str | None = None,
17
+ ) -> dict[str, Any]:
18
+ def _validate_inputs(action_name: str, inputs: list[str] | None) -> None:
19
+ if not inputs:
20
+ raise ValueError(f"inputs parameter is required for {action_name} action")
21
+
22
+ def _validate_time(time_param: float | None) -> None:
23
+ if time_param is None:
24
+ raise ValueError("time parameter is required for wait action")
25
+
26
+ def _validate_action(action_name: str) -> None:
27
+ raise ValueError(f"Unknown action: {action_name}")
28
+
29
+ manager = get_terminal_manager()
30
+
31
+ try:
32
+ match action:
33
+ case "new_terminal":
34
+ return manager.create_terminal(terminal_id, inputs)
35
+
36
+ case "send_input":
37
+ _validate_inputs(action, inputs)
38
+ assert inputs is not None
39
+ return manager.send_input(terminal_id, inputs)
40
+
41
+ case "wait":
42
+ _validate_time(time)
43
+ assert time is not None
44
+ return manager.wait_terminal(terminal_id, time)
45
+
46
+ case "close":
47
+ return manager.close_terminal(terminal_id)
48
+
49
+ case _:
50
+ _validate_action(action) # type: ignore[unreachable]
51
+
52
+ except (ValueError, RuntimeError) as e:
53
+ return {"error": str(e), "terminal_id": terminal_id, "snapshot": "", "is_running": False}
@@ -0,0 +1,114 @@
1
+ <tools>
2
+ <tool name="terminal_action">
3
+ <description>Perform terminal actions using a terminal emulator instance. Each terminal instance
4
+ is PERSISTENT and remains active until explicitly closed, allowing for multi-step
5
+ workflows and long-running processes.</description>
6
+ <parameters>
7
+ <parameter name="action" type="string" required="true">
8
+ <description>The terminal action to perform: - new_terminal: Create a new terminal instance. This MUST be the first action for each terminal tab. - send_input: Send keyboard input to the specified terminal. - wait: Pause execution for specified number of seconds. Can be also used to get the current terminal state (screenshot, output, etc.) after using other tools. - close: Close the specified terminal instance. This MUST be the final action for each terminal tab.</description>
9
+ </parameter>
10
+ <parameter name="inputs" type="string" required="false">
11
+ <description>Required for 'new_terminal' and 'send_input' actions: - List of inputs to send to terminal. Each element in the list MUST be one of the following: - Regular text: "hello", "world", etc. - Literal text (not interpreted as special keys): prefix with "literal:" e.g., "literal:Home", "literal:Escape", "literal:Enter" to send these as text - Enter - Space - Backspace - Escape: "Escape", "^[", "C-[" - Tab: "Tab" - Arrow keys: "Left", "Right", "Up", "Down" - Navigation: "Home", "End", "PageUp", "PageDown" - Function keys: "F1" through "F12" Modifier keys supported with prefixes: - ^ or C- : Control (e.g., "^c", "C-c") - S- : Shift (e.g., "S-F6") - A- : Alt (e.g., "A-Home") - Combined modifiers for arrows: "S-A-Up", "C-S-Left" - Inputs MUST in all cases be sent as a LIST of strings, even if you are only sending one input. - Sending Inputs as a single string will NOT work.</description>
12
+ </parameter>
13
+ <parameter name="time" type="string" required="false">
14
+ <description>Required for 'wait' action. Number of seconds to pause execution. Can be fractional (e.g., 0.5 for half a second).</description>
15
+ </parameter>
16
+ <parameter name="terminal_id" type="string" required="false">
17
+ <description>Identifier for the terminal instance. Required for all actions except the first 'new_terminal' action. Allows managing multiple concurrent terminal tabs. - For 'new_terminal': if not provided, a default terminal is created. If provided, creates a new terminal with that ID. - For other actions: specifies which terminal instance to operate on. - Default terminal ID is "default" if not specified.</description>
18
+ </parameter>
19
+ </parameters>
20
+ <returns type="Dict[str, Any]">
21
+ <description>Response containing: - snapshot: raw representation of current terminal state where you can see the output of the command - terminal_id: the ID of the terminal instance that was operated on</description>
22
+ </returns>
23
+ <notes>
24
+ Important usage rules:
25
+ 1. PERSISTENCE: Terminal instances remain active and maintain their state (environment
26
+ variables, current directory, running processes) until explicitly closed with the
27
+ 'close' action. This allows for multi-step workflows across multiple tool calls.
28
+ 2. MULTIPLE TERMINALS: You can run multiple terminal instances concurrently by using
29
+ different terminal_id values. Each terminal operates independently.
30
+ 3. Terminal interaction MUST begin with 'new_terminal' action for each terminal instance.
31
+ 4. Only one action can be performed per call.
32
+ 5. Input handling:
33
+ - Regular text is sent as-is
34
+ - Literal text: prefix with "literal:" to send special key names as literal text
35
+ - Special keys must match supported key names
36
+ - Modifier combinations follow specific syntax
37
+ - Control can be specified as ^ or C- prefix
38
+ - Shift (S-) works with special keys only
39
+ - Alt (A-) works with any character/key
40
+ 6. Wait action:
41
+ - Time is specified in seconds
42
+ - Can be used to wait for command completion
43
+ - Can be fractional (e.g., 0.5 seconds)
44
+ - Snapshot and output are captured after the wait
45
+ - You should estimate the time it will take to run the command and set the wait time accordingly.
46
+ - It can be from a few seconds to a few minutes, choose wisely depending on the command you are running and the task.
47
+ 7. The terminal can operate concurrently with other tools. You may invoke
48
+ browser, proxy, or other tools (in separate assistant messages) while maintaining
49
+ active terminal sessions.
50
+ 8. You do not need to close terminals after you are done, but you can if you want to
51
+ free up resources.
52
+ 9. You MUST end the inputs list with an "Enter" if you want to run the command, as
53
+ it is not sent automatically.
54
+ 10. AUTOMATIC SPACING BEHAVIOR:
55
+ - Consecutive regular text inputs have spaces automatically added between them
56
+ - This is helpful for shell commands: ["ls", "-la"] becomes "ls -la"
57
+ - This causes problems for compound commands: [":", "w", "q"] becomes ": w q"
58
+ - Use "literal:" prefix to bypass spacing: [":", "literal:wq"] becomes ":wq"
59
+ - Special keys (Enter, Space, etc.) and literal strings never trigger spacing
60
+ 11. WHEN TO USE LITERAL PREFIX:
61
+ - Vim commands: [":", "literal:wq", "Enter"] instead of [":", "w", "q", "Enter"]
62
+ - Any sequence where exact character positioning matters
63
+ - When you need multiple characters sent as a single unit
64
+ 12. Do NOT use terminal actions for file editing or writing. Use the replace_in_file,
65
+ write_to_file, or read_file tools instead.
66
+ </notes>
67
+ <examples>
68
+ # Create new terminal with Node.js (default terminal)
69
+ <function=terminal_action>
70
+ <parameter=action>new_terminal</parameter>
71
+ <parameter=inputs>["node", "Enter"]</parameter>
72
+ </function>
73
+
74
+ # Create a second (parallel) terminal instance for Python
75
+ <function=terminal_action>
76
+ <parameter=action>new_terminal</parameter>
77
+ <parameter=terminal_id>python_terminal</parameter>
78
+ <parameter=inputs>["python3", "Enter"]</parameter>
79
+ </function>
80
+
81
+ # Send command to the default terminal
82
+ <function=terminal_action>
83
+ <parameter=action>send_input</parameter>
84
+ <parameter=inputs>["require('crypto').randomBytes(1000000).toString('hex')",
85
+ "Enter"]</parameter>
86
+ </function>
87
+
88
+ # Wait for previous action on default terminal
89
+ <function=terminal_action>
90
+ <parameter=action>wait</parameter>
91
+ <parameter=time>2.0</parameter>
92
+ </function>
93
+
94
+ # Send multiple inputs with special keys to current terminal
95
+ <function=terminal_action>
96
+ <parameter=action>send_input</parameter>
97
+ <parameter=inputs>["sqlmap -u 'http://example.com/page.php?id=1' --batch", "Enter", "y",
98
+ "Enter", "n", "Enter", "n", "Enter"]</parameter>
99
+ </function>
100
+
101
+ # WRONG: Vim command with automatic spacing (becomes ": w q")
102
+ <function=terminal_action>
103
+ <parameter=action>send_input</parameter>
104
+ <parameter=inputs>[":", "w", "q", "Enter"]</parameter>
105
+ </function>
106
+
107
+ # CORRECT: Vim command using literal prefix (becomes ":wq")
108
+ <function=terminal_action>
109
+ <parameter=action>send_input</parameter>
110
+ <parameter=inputs>[":", "literal:wq", "Enter"]</parameter>
111
+ </function>
112
+ </examples>
113
+ </tool>
114
+ </tools>
@@ -0,0 +1,231 @@
1
+ import contextlib
2
+ import os
3
+ import pty
4
+ import select
5
+ import signal
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ from typing import Any
10
+
11
+ import pyte
12
+
13
+
14
+ MAX_TERMINAL_SNAPSHOT_LENGTH = 10_000
15
+
16
+
17
+ class TerminalInstance:
18
+ def __init__(self, terminal_id: str, initial_command: str | None = None) -> None:
19
+ self.terminal_id = terminal_id
20
+ self.process: subprocess.Popen[bytes] | None = None
21
+ self.master_fd: int | None = None
22
+ self.is_running = False
23
+ self._output_lock = threading.Lock()
24
+ self._reader_thread: threading.Thread | None = None
25
+
26
+ self.screen = pyte.HistoryScreen(80, 24, history=1000)
27
+ self.stream = pyte.ByteStream()
28
+ self.stream.attach(self.screen)
29
+
30
+ self._start_terminal(initial_command)
31
+
32
+ def _start_terminal(self, initial_command: str | None = None) -> None:
33
+ try:
34
+ self.master_fd, slave_fd = pty.openpty()
35
+
36
+ shell = "/bin/bash"
37
+
38
+ self.process = subprocess.Popen( # noqa: S603
39
+ [shell, "-i"],
40
+ stdin=slave_fd,
41
+ stdout=slave_fd,
42
+ stderr=slave_fd,
43
+ cwd="/workspace",
44
+ preexec_fn=os.setsid, # noqa: PLW1509 - Required for PTY functionality
45
+ )
46
+
47
+ os.close(slave_fd)
48
+
49
+ self.is_running = True
50
+
51
+ self._reader_thread = threading.Thread(target=self._read_output, daemon=True)
52
+ self._reader_thread.start()
53
+
54
+ time.sleep(0.5)
55
+
56
+ if initial_command:
57
+ self._write_to_terminal(initial_command)
58
+
59
+ except (OSError, ValueError) as e:
60
+ raise RuntimeError(f"Failed to start terminal: {e}") from e
61
+
62
+ def _read_output(self) -> None:
63
+ while self.is_running and self.master_fd:
64
+ try:
65
+ ready, _, _ = select.select([self.master_fd], [], [], 0.1)
66
+ if ready:
67
+ data = os.read(self.master_fd, 4096)
68
+ if data:
69
+ with self._output_lock, contextlib.suppress(TypeError):
70
+ self.stream.feed(data)
71
+ else:
72
+ break
73
+ except (OSError, ValueError):
74
+ break
75
+
76
+ def _write_to_terminal(self, data: str) -> None:
77
+ if self.master_fd and self.is_running:
78
+ try:
79
+ os.write(self.master_fd, data.encode("utf-8"))
80
+ except (OSError, ValueError) as e:
81
+ raise RuntimeError("Terminal is no longer available") from e
82
+
83
+ def send_input(self, inputs: list[str]) -> None:
84
+ if not self.is_running:
85
+ raise RuntimeError("Terminal is not running")
86
+
87
+ for i, input_item in enumerate(inputs):
88
+ if input_item.startswith("literal:"):
89
+ literal_text = input_item[8:]
90
+ self._write_to_terminal(literal_text)
91
+ else:
92
+ key_sequence = self._get_key_sequence(input_item)
93
+ if key_sequence:
94
+ self._write_to_terminal(key_sequence)
95
+ else:
96
+ self._write_to_terminal(input_item)
97
+
98
+ time.sleep(0.05)
99
+
100
+ if (
101
+ i < len(inputs) - 1
102
+ and not input_item.startswith("literal:")
103
+ and not self._is_special_key(input_item)
104
+ and not inputs[i + 1].startswith("literal:")
105
+ and not self._is_special_key(inputs[i + 1])
106
+ ):
107
+ self._write_to_terminal(" ")
108
+
109
+ def get_snapshot(self) -> dict[str, Any]:
110
+ with self._output_lock:
111
+ history_lines = [
112
+ "".join(char.data for char in line_dict.values())
113
+ for line_dict in self.screen.history.top
114
+ ]
115
+
116
+ current_lines = self.screen.display
117
+
118
+ all_lines = history_lines + current_lines
119
+ rendered_output = "\n".join(all_lines)
120
+
121
+ if len(rendered_output) > MAX_TERMINAL_SNAPSHOT_LENGTH:
122
+ rendered_output = rendered_output[-MAX_TERMINAL_SNAPSHOT_LENGTH:]
123
+ truncated = True
124
+ else:
125
+ truncated = False
126
+
127
+ return {
128
+ "terminal_id": self.terminal_id,
129
+ "snapshot": rendered_output,
130
+ "is_running": self.is_running,
131
+ "process_id": self.process.pid if self.process else None,
132
+ "truncated": truncated,
133
+ }
134
+
135
+ def wait(self, duration: float) -> dict[str, Any]:
136
+ time.sleep(duration)
137
+ return self.get_snapshot()
138
+
139
+ def close(self) -> None:
140
+ self.is_running = False
141
+
142
+ if self.process:
143
+ with contextlib.suppress(OSError, ProcessLookupError):
144
+ os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
145
+
146
+ try:
147
+ self.process.wait(timeout=2)
148
+ except subprocess.TimeoutExpired:
149
+ os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
150
+ self.process.wait()
151
+
152
+ if self.master_fd:
153
+ with contextlib.suppress(OSError):
154
+ os.close(self.master_fd)
155
+ self.master_fd = None
156
+
157
+ if self._reader_thread and self._reader_thread.is_alive():
158
+ self._reader_thread.join(timeout=1)
159
+
160
+ def _is_special_key(self, key: str) -> bool:
161
+ special_keys = {
162
+ "Enter",
163
+ "Space",
164
+ "Backspace",
165
+ "Tab",
166
+ "Escape",
167
+ "Up",
168
+ "Down",
169
+ "Left",
170
+ "Right",
171
+ "Home",
172
+ "End",
173
+ "PageUp",
174
+ "PageDown",
175
+ "Insert",
176
+ "Delete",
177
+ } | {f"F{i}" for i in range(1, 13)}
178
+
179
+ if key in special_keys:
180
+ return True
181
+
182
+ return bool(key.startswith(("^", "C-", "S-", "A-")))
183
+
184
+ def _get_key_sequence(self, key: str) -> str | None:
185
+ key_map = {
186
+ "Enter": "\r",
187
+ "Space": " ",
188
+ "Backspace": "\x08",
189
+ "Tab": "\t",
190
+ "Escape": "\x1b",
191
+ "Up": "\x1b[A",
192
+ "Down": "\x1b[B",
193
+ "Right": "\x1b[C",
194
+ "Left": "\x1b[D",
195
+ "Home": "\x1b[H",
196
+ "End": "\x1b[F",
197
+ "PageUp": "\x1b[5~",
198
+ "PageDown": "\x1b[6~",
199
+ "Insert": "\x1b[2~",
200
+ "Delete": "\x1b[3~",
201
+ "F1": "\x1b[11~",
202
+ "F2": "\x1b[12~",
203
+ "F3": "\x1b[13~",
204
+ "F4": "\x1b[14~",
205
+ "F5": "\x1b[15~",
206
+ "F6": "\x1b[17~",
207
+ "F7": "\x1b[18~",
208
+ "F8": "\x1b[19~",
209
+ "F9": "\x1b[20~",
210
+ "F10": "\x1b[21~",
211
+ "F11": "\x1b[23~",
212
+ "F12": "\x1b[24~",
213
+ }
214
+
215
+ if key in key_map:
216
+ return key_map[key]
217
+
218
+ if key.startswith("^") and len(key) == 2:
219
+ char = key[1].lower()
220
+ return chr(ord(char) - ord("a") + 1) if "a" <= char <= "z" else None
221
+
222
+ if key.startswith("C-") and len(key) == 3:
223
+ char = key[2].lower()
224
+ return chr(ord(char) - ord("a") + 1) if "a" <= char <= "z" else None
225
+
226
+ return None
227
+
228
+ def is_alive(self) -> bool:
229
+ if not self.process:
230
+ return False
231
+ return self.process.poll() is None