python-codex 0.1.2__py3-none-any.whl → 0.1.3__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 (56) hide show
  1. pycodex/__init__.py +5 -1
  2. pycodex/agent.py +39 -41
  3. pycodex/cli.py +43 -42
  4. pycodex/collaboration.py +6 -7
  5. pycodex/compat.py +99 -0
  6. pycodex/context.py +87 -87
  7. pycodex/doctor.py +40 -40
  8. pycodex/model.py +69 -69
  9. pycodex/portable.py +33 -33
  10. pycodex/portable_server.py +22 -21
  11. pycodex/protocol.py +84 -86
  12. pycodex/runtime.py +36 -35
  13. pycodex/runtime_services.py +69 -69
  14. pycodex/tools/agent_tool_schemas.py +0 -2
  15. pycodex/tools/apply_patch_tool.py +43 -44
  16. pycodex/tools/base_tool.py +35 -36
  17. pycodex/tools/close_agent_tool.py +2 -4
  18. pycodex/tools/code_mode_manager.py +61 -61
  19. pycodex/tools/exec_command_tool.py +5 -6
  20. pycodex/tools/exec_runtime.js +3 -3
  21. pycodex/tools/exec_tool.py +2 -4
  22. pycodex/tools/grep_files_tool.py +10 -11
  23. pycodex/tools/list_dir_tool.py +8 -9
  24. pycodex/tools/read_file_tool.py +13 -14
  25. pycodex/tools/request_permissions_tool.py +2 -4
  26. pycodex/tools/request_user_input_tool.py +13 -14
  27. pycodex/tools/resume_agent_tool.py +2 -4
  28. pycodex/tools/send_input_tool.py +8 -9
  29. pycodex/tools/shell_command_tool.py +5 -6
  30. pycodex/tools/shell_tool.py +5 -6
  31. pycodex/tools/spawn_agent_tool.py +4 -5
  32. pycodex/tools/unified_exec_manager.py +62 -61
  33. pycodex/tools/update_plan_tool.py +4 -5
  34. pycodex/tools/view_image_tool.py +4 -5
  35. pycodex/tools/wait_agent_tool.py +2 -4
  36. pycodex/tools/wait_tool.py +4 -5
  37. pycodex/tools/web_search_tool.py +1 -3
  38. pycodex/tools/write_stdin_tool.py +4 -5
  39. pycodex/utils/dotenv.py +6 -6
  40. pycodex/utils/get_env.py +37 -33
  41. pycodex/utils/random_ids.py +1 -2
  42. pycodex/utils/visualize.py +79 -79
  43. {python_codex-0.1.2.dist-info → python_codex-0.1.3.dist-info}/METADATA +15 -9
  44. python_codex-0.1.3.dist-info/RECORD +74 -0
  45. {python_codex-0.1.2.dist-info → python_codex-0.1.3.dist-info}/WHEEL +1 -1
  46. responses_server/app.py +29 -19
  47. responses_server/config.py +17 -17
  48. responses_server/payload_processors.py +16 -16
  49. responses_server/server.py +11 -11
  50. responses_server/session_store.py +10 -10
  51. responses_server/stream_router.py +58 -58
  52. responses_server/tools/custom_adapter.py +12 -12
  53. responses_server/tools/web_search.py +33 -33
  54. python_codex-0.1.2.dist-info/RECORD +0 -73
  55. {python_codex-0.1.2.dist-info → python_codex-0.1.3.dist-info}/entry_points.txt +0 -0
  56. {python_codex-0.1.2.dist-info → python_codex-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -9,13 +9,12 @@ Expected behavior:
9
9
  mode used to inspect code structure without shelling out to `sed` or `cat`.
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  from collections import deque
15
13
  from pathlib import Path
16
14
 
17
15
  from ..protocol import JSONDict, JSONValue
18
16
  from .base_tool import BaseTool, ToolContext
17
+ import typing
19
18
 
20
19
  MAX_LINE_LENGTH = 500
21
20
  TAB_WIDTH = 4
@@ -49,7 +48,7 @@ class ReadFileTool(BaseTool):
49
48
  "required": ["file_path"],
50
49
  }
51
50
 
52
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
51
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
53
52
  del context
54
53
  file_path = Path(str(args.get("file_path", "")))
55
54
  offset = int(args.get("offset", 1))
@@ -72,7 +71,7 @@ class ReadFileTool(BaseTool):
72
71
  return self._read_indentation(file_path, offset, limit, indentation)
73
72
  return self._read_slice(file_path, offset, limit)
74
73
 
75
- def _read_slice(self, file_path: Path, offset: int, limit: int) -> str:
74
+ def _read_slice(self, file_path: 'Path', offset: 'int', limit: 'int') -> 'str':
76
75
  lines = file_path.read_text(errors="replace").splitlines()
77
76
  if offset > len(lines):
78
77
  return "Error: `offset` exceeds file length."
@@ -85,11 +84,11 @@ class ReadFileTool(BaseTool):
85
84
 
86
85
  def _read_indentation(
87
86
  self,
88
- file_path: Path,
89
- offset: int,
90
- limit: int,
91
- indentation: JSONDict,
92
- ) -> str:
87
+ file_path: 'Path',
88
+ offset: 'int',
89
+ limit: 'int',
90
+ indentation: 'JSONDict',
91
+ ) -> 'str':
93
92
  lines = self._collect_line_records(file_path)
94
93
  anchor_line = int(indentation.get("anchor_line", offset))
95
94
  max_levels = int(indentation.get("max_levels", 0))
@@ -171,7 +170,7 @@ class ReadFileTool(BaseTool):
171
170
  f"L{record['number']}: {record['display']}" for record in selected
172
171
  )
173
172
 
174
- def _collect_line_records(self, file_path: Path) -> list[dict[str, object]]:
173
+ def _collect_line_records(self, file_path: 'Path') -> 'typing.List[typing.Dict[str, object]]':
175
174
  records = []
176
175
  for number, raw in enumerate(file_path.read_text(errors="replace").splitlines(), start=1):
177
176
  records.append(
@@ -185,7 +184,7 @@ class ReadFileTool(BaseTool):
185
184
  )
186
185
  return records
187
186
 
188
- def _compute_effective_indents(self, records: list[dict[str, object]]) -> list[int]:
187
+ def _compute_effective_indents(self, records: 'typing.List[typing.Dict[str, object]]') -> 'typing.List[int]':
189
188
  effective = []
190
189
  previous_indent = 0
191
190
  for record in records:
@@ -196,7 +195,7 @@ class ReadFileTool(BaseTool):
196
195
  effective.append(previous_indent)
197
196
  return effective
198
197
 
199
- def _measure_indent(self, line: str) -> int:
198
+ def _measure_indent(self, line: 'str') -> 'int':
200
199
  total = 0
201
200
  for character in line:
202
201
  if character == " ":
@@ -207,10 +206,10 @@ class ReadFileTool(BaseTool):
207
206
  break
208
207
  return total
209
208
 
210
- def _format_line(self, text: str) -> str:
209
+ def _format_line(self, text: 'str') -> 'str':
211
210
  return text[:MAX_LINE_LENGTH]
212
211
 
213
- def _trim_empty_lines(self, records: deque[dict[str, object]]) -> None:
212
+ def _trim_empty_lines(self, records: 'deque[typing.Dict[str, object]]') -> 'None':
214
213
  while records and not str(records[0]["raw"]).strip():
215
214
  records.popleft()
216
215
  while records and not str(records[-1]["raw"]).strip():
@@ -9,8 +9,6 @@ Expected behavior:
9
9
  - Return the granted permission profile and scope so later tool calls can use it.
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  from ..protocol import JSONDict, JSONValue
15
13
  from ..runtime_services import RequestPermissionsManager
16
14
  from .base_tool import BaseTool, ToolContext
@@ -73,10 +71,10 @@ class RequestPermissionsTool(BaseTool):
73
71
  }
74
72
  supports_parallel = False
75
73
 
76
- def __init__(self, request_manager: RequestPermissionsManager) -> None:
74
+ def __init__(self, request_manager: 'RequestPermissionsManager') -> 'None':
77
75
  self._request_manager = request_manager
78
76
 
79
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
77
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
80
78
  del context
81
79
  permissions = args.get("permissions")
82
80
  if not isinstance(permissions, dict):
@@ -12,14 +12,13 @@ Expected behavior:
12
12
  `success=true`.
13
13
  """
14
14
 
15
- from __future__ import annotations
16
-
17
15
  import json
18
16
 
19
17
  from ..collaboration import collaboration_mode_display_name
20
18
  from ..protocol import JSONDict, JSONValue
21
19
  from ..runtime_services import RequestUserInputManager
22
20
  from .base_tool import BaseTool, StructuredToolOutput, ToolContext
21
+ import typing
23
22
 
24
23
  REQUEST_USER_INPUT_QUESTION_SCHEMA = {
25
24
  "type": "object",
@@ -67,9 +66,9 @@ REQUEST_USER_INPUT_QUESTION_SCHEMA = {
67
66
 
68
67
 
69
68
  def request_user_input_is_available(
70
- mode: str,
71
- default_mode_request_user_input: bool = False,
72
- ) -> bool:
69
+ mode: 'str',
70
+ default_mode_request_user_input: 'bool' = False,
71
+ ) -> 'bool':
73
72
  normalized = mode.strip().lower()
74
73
  return normalized == "plan" or (
75
74
  default_mode_request_user_input and normalized == "default"
@@ -77,9 +76,9 @@ def request_user_input_is_available(
77
76
 
78
77
 
79
78
  def request_user_input_unavailable_message(
80
- mode: str,
81
- default_mode_request_user_input: bool = False,
82
- ) -> str | None:
79
+ mode: 'str',
80
+ default_mode_request_user_input: 'bool' = False,
81
+ ) -> 'typing.Union[str, None]':
83
82
  if request_user_input_is_available(mode, default_mode_request_user_input):
84
83
  return None
85
84
  return (
@@ -89,8 +88,8 @@ def request_user_input_unavailable_message(
89
88
 
90
89
 
91
90
  def request_user_input_tool_description(
92
- default_mode_request_user_input: bool = False,
93
- ) -> str:
91
+ default_mode_request_user_input: 'bool' = False,
92
+ ) -> 'str':
94
93
  if default_mode_request_user_input:
95
94
  allowed_modes = "Default or Plan mode"
96
95
  else:
@@ -120,16 +119,16 @@ class RequestUserInputTool(BaseTool):
120
119
 
121
120
  def __init__(
122
121
  self,
123
- request_manager: RequestUserInputManager,
124
- default_mode_request_user_input: bool = False,
125
- ) -> None:
122
+ request_manager: 'RequestUserInputManager',
123
+ default_mode_request_user_input: 'bool' = False,
124
+ ) -> 'None':
126
125
  self._request_manager = request_manager
127
126
  self._default_mode_request_user_input = default_mode_request_user_input
128
127
  self.description = request_user_input_tool_description(
129
128
  default_mode_request_user_input
130
129
  )
131
130
 
132
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
131
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
133
132
  unavailable = request_user_input_unavailable_message(
134
133
  context.collaboration_mode,
135
134
  self._default_mode_request_user_input,
@@ -8,8 +8,6 @@ Expected behavior:
8
8
  - Return the agent's current status payload.
9
9
  """
10
10
 
11
- from __future__ import annotations
12
-
13
11
  from ..protocol import JSONDict, JSONValue
14
12
  from ..runtime_services import SubAgentManager
15
13
  from .agent_tool_schemas import AGENT_STATUS_SCHEMA
@@ -45,10 +43,10 @@ class ResumeAgentTool(BaseTool):
45
43
  output_schema = RESUME_AGENT_OUTPUT_SCHEMA
46
44
  supports_parallel = False
47
45
 
48
- def __init__(self, subagent_manager: SubAgentManager) -> None:
46
+ def __init__(self, subagent_manager: 'SubAgentManager') -> 'None':
49
47
  self._subagent_manager = subagent_manager
50
48
 
51
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
49
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
52
50
  del context
53
51
  agent_id = str(args.get("id", "")).strip()
54
52
  if not agent_id:
@@ -9,12 +9,11 @@ Expected behavior:
9
9
  - Return the submission id for the queued input.
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  from ..protocol import JSONDict, JSONValue
15
13
  from ..runtime_services import SubAgentManager
16
14
  from .agent_tool_schemas import COLLAB_INPUT_ITEMS_SCHEMA
17
15
  from .base_tool import BaseTool, ToolContext
16
+ import typing
18
17
 
19
18
  SEND_INPUT_OUTPUT_SCHEMA = {
20
19
  "type": "object",
@@ -58,10 +57,10 @@ class SendInputTool(BaseTool):
58
57
  output_schema = SEND_INPUT_OUTPUT_SCHEMA
59
58
  supports_parallel = False
60
59
 
61
- def __init__(self, subagent_manager: SubAgentManager) -> None:
60
+ def __init__(self, subagent_manager: 'SubAgentManager') -> 'None':
62
61
  self._subagent_manager = subagent_manager
63
62
 
64
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
63
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
65
64
  del context
66
65
  agent_id = str(args.get("id", "")).strip()
67
66
  if not agent_id:
@@ -83,10 +82,10 @@ class SendInputTool(BaseTool):
83
82
 
84
83
  def _compose_prompt(
85
84
  self,
86
- message: str | None,
87
- items: list[dict[str, object]] | None,
88
- ) -> str:
89
- parts: list[str] = []
85
+ message: 'typing.Union[str, None]',
86
+ items: 'typing.Union[typing.List[typing.Dict[str, object]], None]',
87
+ ) -> 'str':
88
+ parts: 'typing.List[str]' = []
90
89
  if message:
91
90
  parts.append(message.strip())
92
91
  for item in items or []:
@@ -99,7 +98,7 @@ class SendInputTool(BaseTool):
99
98
  parts.append(str(item))
100
99
  return "\n\n".join(part for part in parts if part)
101
100
 
102
- def _optional_string(self, args: JSONDict, key: str) -> str | None:
101
+ def _optional_string(self, args: 'JSONDict', key: 'str') -> 'typing.Union[str, None]':
103
102
  value = args.get(key)
104
103
  if value in (None, ""):
105
104
  return None
@@ -11,13 +11,12 @@ Expected behavior:
11
11
  stdout, and stderr.
12
12
  """
13
13
 
14
- from __future__ import annotations
15
-
16
14
  import asyncio
17
15
  from pathlib import Path
18
16
 
19
17
  from ..protocol import JSONDict, JSONValue
20
18
  from .base_tool import BaseTool, ToolContext
19
+ import typing
21
20
 
22
21
  DEFAULT_SHELL_TIMEOUT_MS = 30_000
23
22
  MAX_OUTPUT_CHARS = 12_000
@@ -38,10 +37,10 @@ class ShellCommandTool(BaseTool):
38
37
  }
39
38
  supports_parallel = False
40
39
 
41
- def __init__(self, cwd: str | Path | None = None) -> None:
40
+ def __init__(self, cwd: 'typing.Union[typing.Union[str, Path], None]' = None) -> 'None':
42
41
  self._working_directory = Path(cwd or Path.cwd()).resolve()
43
42
 
44
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
43
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
45
44
  del context
46
45
  command = str(args.get("command", "")).strip()
47
46
  timeout_ms = int(args.get("timeout_ms", DEFAULT_SHELL_TIMEOUT_MS))
@@ -93,7 +92,7 @@ class ShellCommandTool(BaseTool):
93
92
 
94
93
  return "\n".join(pieces)
95
94
 
96
- def _resolve_workdir(self, workdir_arg) -> Path:
95
+ def _resolve_workdir(self, workdir_arg) -> 'Path':
97
96
  if workdir_arg in (None, ""):
98
97
  return self._working_directory
99
98
  workdir = Path(str(workdir_arg))
@@ -101,7 +100,7 @@ class ShellCommandTool(BaseTool):
101
100
  workdir = self._working_directory / workdir
102
101
  return workdir.resolve()
103
102
 
104
- def _clip_output(self, text: str) -> str:
103
+ def _clip_output(self, text: 'str') -> 'str':
105
104
  if len(text) <= MAX_OUTPUT_CHARS:
106
105
  return text
107
106
  return text[:MAX_OUTPUT_CHARS] + "\n...[truncated]..."
@@ -11,13 +11,12 @@ Expected behavior:
11
11
  stdout, and stderr.
12
12
  """
13
13
 
14
- from __future__ import annotations
15
-
16
14
  import asyncio
17
15
  from pathlib import Path
18
16
 
19
17
  from ..protocol import JSONDict, JSONValue
20
18
  from .base_tool import BaseTool, ToolContext
19
+ import typing
21
20
 
22
21
  DEFAULT_SHELL_TIMEOUT_MS = 30_000
23
22
  MAX_OUTPUT_CHARS = 12_000
@@ -43,10 +42,10 @@ class ShellTool(BaseTool):
43
42
  }
44
43
  supports_parallel = False
45
44
 
46
- def __init__(self, cwd: str | Path | None = None) -> None:
45
+ def __init__(self, cwd: 'typing.Union[typing.Union[str, Path], None]' = None) -> 'None':
47
46
  self._working_directory = Path(cwd or Path.cwd()).resolve()
48
47
 
49
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
48
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
50
49
  del context
51
50
  command = args.get("command")
52
51
  timeout_ms = int(args.get("timeout_ms", DEFAULT_SHELL_TIMEOUT_MS))
@@ -98,7 +97,7 @@ class ShellTool(BaseTool):
98
97
 
99
98
  return "\n".join(pieces)
100
99
 
101
- def _resolve_workdir(self, workdir_arg) -> Path:
100
+ def _resolve_workdir(self, workdir_arg) -> 'Path':
102
101
  if workdir_arg in (None, ""):
103
102
  return self._working_directory
104
103
  workdir = Path(str(workdir_arg))
@@ -106,7 +105,7 @@ class ShellTool(BaseTool):
106
105
  workdir = self._working_directory / workdir
107
106
  return workdir.resolve()
108
107
 
109
- def _clip_output(self, text: str) -> str:
108
+ def _clip_output(self, text: 'str') -> 'str':
110
109
  if len(text) <= MAX_OUTPUT_CHARS:
111
110
  return text
112
111
  return text[:MAX_OUTPUT_CHARS] + "\n...[truncated]..."
@@ -9,12 +9,11 @@ Expected behavior:
9
9
  - Return the new agent identifier plus any user-facing nickname.
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  from ..protocol import JSONDict, JSONValue
15
13
  from ..runtime_services import SubAgentManager
16
14
  from .agent_tool_schemas import COLLAB_INPUT_ITEMS_SCHEMA
17
15
  from .base_tool import BaseTool, ToolContext
16
+ import typing
18
17
 
19
18
  SPAWN_AGENT_OUTPUT_SCHEMA = {
20
19
  "type": "object",
@@ -70,10 +69,10 @@ class SpawnAgentTool(BaseTool):
70
69
  output_schema = SPAWN_AGENT_OUTPUT_SCHEMA
71
70
  supports_parallel = False
72
71
 
73
- def __init__(self, subagent_manager: SubAgentManager) -> None:
72
+ def __init__(self, subagent_manager: 'SubAgentManager') -> 'None':
74
73
  self._subagent_manager = subagent_manager
75
74
 
76
- async def run(self, context: ToolContext, args: JSONDict) -> JSONValue:
75
+ async def run(self, context: 'ToolContext', args: 'JSONDict') -> 'JSONValue':
77
76
  message = self._optional_string(args, "message")
78
77
  items = args.get("items")
79
78
  if items is not None and not isinstance(items, list):
@@ -90,7 +89,7 @@ class SpawnAgentTool(BaseTool):
90
89
  history=context.history,
91
90
  )
92
91
 
93
- def _optional_string(self, args: JSONDict, key: str) -> str | None:
92
+ def _optional_string(self, args: 'JSONDict', key: 'str') -> 'typing.Union[str, None]':
94
93
  value = args.get(key)
95
94
  if value in (None, ""):
96
95
  return None
@@ -11,8 +11,6 @@ Expected behavior:
11
11
  - Return summaries in the same textual shape Codex tools expect.
12
12
  """
13
13
 
14
- from __future__ import annotations
15
-
16
14
  import asyncio
17
15
  import os
18
16
  import shlex
@@ -22,6 +20,9 @@ from pathlib import Path
22
20
 
23
21
  from loguru import logger
24
22
 
23
+ from ..compat import shlex_join, stream_writer_is_closing
24
+ import typing
25
+
25
26
  DEFAULT_EXEC_YIELD_TIME_MS = 10_000
26
27
  DEFAULT_WRITE_STDIN_YIELD_TIME_MS = 250
27
28
  DEFAULT_MAX_OUTPUT_TOKENS = 10_000
@@ -63,33 +64,33 @@ UNIFIED_EXEC_OUTPUT_SCHEMA = {
63
64
  }
64
65
 
65
66
 
66
- def _approx_token_count(text: str) -> int:
67
+ def _approx_token_count(text: 'str') -> 'int':
67
68
  if not text:
68
69
  return 0
69
70
  byte_length = len(text.encode("utf-8"))
70
71
  return max(1, (byte_length + APPROX_BYTES_PER_TOKEN - 1) // APPROX_BYTES_PER_TOKEN)
71
72
 
72
73
 
73
- def _approx_bytes_for_tokens(token_count: int) -> int:
74
+ def _approx_bytes_for_tokens(token_count: 'int') -> 'int':
74
75
  return max(token_count, 0) * APPROX_BYTES_PER_TOKEN
75
76
 
76
77
 
77
- def _approx_tokens_from_byte_count(byte_count: int) -> int:
78
+ def _approx_tokens_from_byte_count(byte_count: 'int') -> 'int':
78
79
  if byte_count <= 0:
79
80
  return 0
80
81
  return (byte_count + APPROX_BYTES_PER_TOKEN - 1) // APPROX_BYTES_PER_TOKEN
81
82
 
82
83
 
83
- def _split_budget(byte_budget: int) -> tuple[int, int]:
84
+ def _split_budget(byte_budget: 'int') -> 'typing.Tuple[int, int]':
84
85
  left_budget = byte_budget // 2
85
86
  return left_budget, byte_budget - left_budget
86
87
 
87
88
 
88
89
  def _split_string(
89
- text: str,
90
- beginning_bytes: int,
91
- end_bytes: int,
92
- ) -> tuple[str, str]:
90
+ text: 'str',
91
+ beginning_bytes: 'int',
92
+ end_bytes: 'int',
93
+ ) -> 'typing.Tuple[str, str]':
93
94
  if not text:
94
95
  return "", ""
95
96
 
@@ -122,7 +123,7 @@ def _split_string(
122
123
  return text[:prefix_end], text[suffix_start:]
123
124
 
124
125
 
125
- def _truncate_text(text: str, max_tokens: int) -> str:
126
+ def _truncate_text(text: 'str', max_tokens: 'int') -> 'str':
126
127
  if not text:
127
128
  return ""
128
129
 
@@ -141,7 +142,7 @@ def _truncate_text(text: str, max_tokens: int) -> str:
141
142
  return f"{prefix}{marker}{suffix}"
142
143
 
143
144
 
144
- def _formatted_truncate_text(text: str, max_tokens: int) -> str:
145
+ def _formatted_truncate_text(text: 'str', max_tokens: 'int') -> 'str':
145
146
  byte_budget = _approx_bytes_for_tokens(max_tokens)
146
147
  if len(text.encode("utf-8")) <= byte_budget:
147
148
  return text
@@ -150,13 +151,13 @@ def _formatted_truncate_text(text: str, max_tokens: int) -> str:
150
151
  return f"Total output lines: {total_lines}\n\n{_truncate_text(text, max_tokens)}"
151
152
 
152
153
 
153
- @dataclass(slots=True)
154
+ @dataclass
154
155
  class _HeadTailBuffer:
155
- max_bytes: int = UNIFIED_EXEC_OUTPUT_MAX_BYTES
156
- head: bytearray = field(default_factory=bytearray)
157
- tail: bytearray = field(default_factory=bytearray)
156
+ max_bytes: 'int' = UNIFIED_EXEC_OUTPUT_MAX_BYTES
157
+ head: 'bytearray' = field(default_factory=bytearray)
158
+ tail: 'bytearray' = field(default_factory=bytearray)
158
159
 
159
- def push_chunk(self, chunk: bytes) -> None:
160
+ def push_chunk(self, chunk: 'bytes') -> 'None':
160
161
  if not chunk or self.max_bytes <= 0:
161
162
  return
162
163
 
@@ -178,45 +179,45 @@ class _HeadTailBuffer:
178
179
  excess = len(self.tail) - tail_budget
179
180
  del self.tail[:excess]
180
181
 
181
- def drain_bytes(self) -> bytes:
182
+ def drain_bytes(self) -> 'bytes':
182
183
  combined = bytes(self.head) + bytes(self.tail)
183
184
  self.head.clear()
184
185
  self.tail.clear()
185
186
  return combined
186
187
 
187
- def has_data(self) -> bool:
188
+ def has_data(self) -> 'bool':
188
189
  return bool(self.head or self.tail)
189
190
 
190
191
 
191
- @dataclass(slots=True)
192
+ @dataclass
192
193
  class UnifiedExecSession:
193
- session_id: int
194
- process: asyncio.subprocess.Process
195
- start_time: float
196
- command_display: str
197
- tty: bool
198
- unread_output: _HeadTailBuffer = field(default_factory=_HeadTailBuffer)
199
- reader_task: asyncio.Task | None = None
200
- output_event: asyncio.Event = field(default_factory=asyncio.Event)
194
+ session_id: 'int'
195
+ process: 'asyncio.subprocess.Process'
196
+ start_time: 'float'
197
+ command_display: 'str'
198
+ tty: 'bool'
199
+ unread_output: '_HeadTailBuffer' = field(default_factory=_HeadTailBuffer)
200
+ reader_task: 'typing.Union[asyncio.Task, None]' = None
201
+ output_event: 'asyncio.Event' = field(default_factory=asyncio.Event)
201
202
 
202
203
 
203
204
  class UnifiedExecManager:
204
- def __init__(self, cwd: str | Path | None = None) -> None:
205
+ def __init__(self, cwd: 'typing.Union[typing.Union[str, Path], None]' = None) -> 'None':
205
206
  self._default_cwd = Path(cwd or Path.cwd()).resolve()
206
207
  self._next_session_id = DEFAULT_SESSION_ID_START
207
- self._sessions: dict[int, UnifiedExecSession] = {}
208
+ self._sessions: 'typing.Dict[int, UnifiedExecSession]' = {}
208
209
  self._lock = asyncio.Lock()
209
210
 
210
211
  async def exec_command(
211
212
  self,
212
- cmd: str,
213
- workdir: str | None = None,
214
- shell: str | None = None,
215
- login: bool = DEFAULT_LOGIN,
216
- tty: bool = DEFAULT_TTY,
217
- yield_time_ms: int = DEFAULT_EXEC_YIELD_TIME_MS,
218
- max_output_tokens: int | None = None,
219
- ) -> str:
213
+ cmd: 'str',
214
+ workdir: 'typing.Union[str, None]' = None,
215
+ shell: 'typing.Union[str, None]' = None,
216
+ login: 'bool' = DEFAULT_LOGIN,
217
+ tty: 'bool' = DEFAULT_TTY,
218
+ yield_time_ms: 'int' = DEFAULT_EXEC_YIELD_TIME_MS,
219
+ max_output_tokens: 'typing.Union[int, None]' = None,
220
+ ) -> 'str':
220
221
  session_id = await self._allocate_session_id()
221
222
  command = self._build_shell_command(cmd, shell, login)
222
223
  cwd = self._resolve_workdir(workdir)
@@ -238,7 +239,7 @@ class UnifiedExecManager:
238
239
  session_id=session_id,
239
240
  process=process,
240
241
  start_time=asyncio.get_running_loop().time(),
241
- command_display=shlex.join(command),
242
+ command_display=shlex_join(command),
242
243
  tty=tty,
243
244
  )
244
245
  session.reader_task = asyncio.create_task(self._pump_output(session))
@@ -254,11 +255,11 @@ class UnifiedExecManager:
254
255
 
255
256
  async def write_stdin(
256
257
  self,
257
- session_id: int,
258
- chars: str = "",
259
- yield_time_ms: int = DEFAULT_WRITE_STDIN_YIELD_TIME_MS,
260
- max_output_tokens: int | None = None,
261
- ) -> str:
258
+ session_id: 'int',
259
+ chars: 'str' = "",
260
+ yield_time_ms: 'int' = DEFAULT_WRITE_STDIN_YIELD_TIME_MS,
261
+ max_output_tokens: 'typing.Union[int, None]' = None,
262
+ ) -> 'str':
262
263
  session = await self._get_session(session_id)
263
264
  if session is None:
264
265
  return f"Error: session_id {session_id} is not running."
@@ -278,22 +279,22 @@ class UnifiedExecManager:
278
279
  max_output_tokens,
279
280
  )
280
281
 
281
- async def _allocate_session_id(self) -> int:
282
+ async def _allocate_session_id(self) -> 'int':
282
283
  async with self._lock:
283
284
  session_id = self._next_session_id
284
285
  self._next_session_id += 1
285
286
  return session_id
286
287
 
287
- async def _get_session(self, session_id: int) -> UnifiedExecSession | None:
288
+ async def _get_session(self, session_id: 'int') -> 'typing.Union[UnifiedExecSession, None]':
288
289
  async with self._lock:
289
290
  return self._sessions.get(session_id)
290
291
 
291
292
  async def _wait_and_snapshot(
292
293
  self,
293
- session_id: int,
294
- yield_time_ms: int,
295
- max_output_tokens: int | None,
296
- ) -> str:
294
+ session_id: 'int',
295
+ yield_time_ms: 'int',
296
+ max_output_tokens: 'typing.Union[int, None]',
297
+ ) -> 'str':
297
298
  session = await self._get_session(session_id)
298
299
  if session is None:
299
300
  return f"Error: session_id {session_id} is not running."
@@ -343,15 +344,15 @@ class UnifiedExecManager:
343
344
 
344
345
  return "\n".join(lines)
345
346
 
346
- async def _close_session(self, session_id: int) -> None:
347
+ async def _close_session(self, session_id: 'int') -> 'None':
347
348
  async with self._lock:
348
349
  session = self._sessions.pop(session_id, None)
349
350
  if session is None:
350
351
  return
351
- if session.process.stdin is not None and not session.process.stdin.is_closing():
352
+ if session.process.stdin is not None and not stream_writer_is_closing(session.process.stdin):
352
353
  session.process.stdin.close()
353
354
 
354
- async def _pump_output(self, session: UnifiedExecSession) -> None:
355
+ async def _pump_output(self, session: 'UnifiedExecSession') -> 'None':
355
356
  stream = session.process.stdout
356
357
  if stream is None:
357
358
  return
@@ -363,7 +364,7 @@ class UnifiedExecManager:
363
364
  session.output_event.set()
364
365
  session.output_event.set()
365
366
 
366
- def _resolve_workdir(self, workdir: str | None) -> Path:
367
+ def _resolve_workdir(self, workdir: 'typing.Union[str, None]') -> 'Path':
367
368
  if not workdir:
368
369
  return self._default_cwd
369
370
  path = Path(workdir)
@@ -373,10 +374,10 @@ class UnifiedExecManager:
373
374
 
374
375
  def _build_shell_command(
375
376
  self,
376
- cmd: str,
377
- shell: str | None,
378
- login: bool,
379
- ) -> list[str]:
377
+ cmd: 'str',
378
+ shell: 'typing.Union[str, None]',
379
+ login: 'bool',
380
+ ) -> 'typing.List[str]':
380
381
  shell_path = shell or os.environ.get("SHELL") or "/bin/bash"
381
382
  shell_name = Path(shell_path).name.lower()
382
383
  if shell_name in {"cmd", "cmd.exe"}:
@@ -385,13 +386,13 @@ class UnifiedExecManager:
385
386
  return [shell_path, "-NoProfile", "-Command", cmd]
386
387
  return [shell_path, "-lc" if login else "-c", cmd]
387
388
 
388
- def _estimate_token_count(self, output: str) -> int | None:
389
+ def _estimate_token_count(self, output: 'str') -> 'typing.Union[int, None]':
389
390
  return _approx_token_count(output)
390
391
 
391
- def _truncate_output(self, output: str, max_output_tokens: int | None) -> str:
392
+ def _truncate_output(self, output: 'str', max_output_tokens: 'typing.Union[int, None]') -> 'str':
392
393
  token_budget = DEFAULT_MAX_OUTPUT_TOKENS if max_output_tokens is None else max_output_tokens
393
394
  return _formatted_truncate_text(output, max(token_budget, 0))
394
395
 
395
- def _tty_echo(self, chars: str) -> bytes:
396
+ def _tty_echo(self, chars: 'str') -> 'bytes':
396
397
  normalized = chars.replace("\n", "\r\n")
397
398
  return normalized.encode("utf-8")