minimax-code-sdk 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.
@@ -0,0 +1,66 @@
1
+ """Python SDK for driving the local MiniMax Code (``mcode``) agent."""
2
+
3
+ from .client import MCode
4
+ from .errors import (
5
+ MCODE_EXEC_EXIT_CODES,
6
+ MCodeAbortError,
7
+ MCodeError,
8
+ MCodeInvocationError,
9
+ MCodeNotFoundError,
10
+ MCodeProtocolError,
11
+ MCodeRunError,
12
+ MCodeVersionError,
13
+ ThreadBusyError,
14
+ )
15
+ from .sync import SyncMCode, SyncThread
16
+ from .thread import StreamedTurn, Thread
17
+ from .types import (
18
+ AgentMessageItem,
19
+ ExecError,
20
+ ExecItem,
21
+ ExecResult,
22
+ MCodeOptions,
23
+ ModelIdentity,
24
+ ReasoningItem,
25
+ RunOptions,
26
+ ThreadEvent,
27
+ ThreadItem,
28
+ ThreadOptions,
29
+ ToolCallItem,
30
+ Turn,
31
+ Usage,
32
+ )
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "MCODE_EXEC_EXIT_CODES",
38
+ "AgentMessageItem",
39
+ "ExecError",
40
+ "ExecItem",
41
+ "ExecResult",
42
+ "MCode",
43
+ "MCodeAbortError",
44
+ "MCodeError",
45
+ "MCodeInvocationError",
46
+ "MCodeNotFoundError",
47
+ "MCodeOptions",
48
+ "MCodeProtocolError",
49
+ "MCodeRunError",
50
+ "MCodeVersionError",
51
+ "ModelIdentity",
52
+ "ReasoningItem",
53
+ "RunOptions",
54
+ "StreamedTurn",
55
+ "SyncMCode",
56
+ "SyncThread",
57
+ "Thread",
58
+ "ThreadBusyError",
59
+ "ThreadEvent",
60
+ "ThreadItem",
61
+ "ThreadOptions",
62
+ "ToolCallItem",
63
+ "Turn",
64
+ "Usage",
65
+ "__version__",
66
+ ]
minimax_code/_args.py ADDED
@@ -0,0 +1,90 @@
1
+ """Builds ``mcode exec`` argv. Mirrors ``packages/code-sdk/src/args.ts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from .errors import MCodeInvocationError
8
+ from .types import RunOptions, ThreadOptions
9
+
10
+ _PERMISSIONS = {"smart", "full", "off"}
11
+ _MODES = {"standard", "lightweight"}
12
+ _PROMPT_MODES = {"tui", "coding", "work"}
13
+ MAX_TIMEOUT_MS = 2**31 - 1
14
+
15
+
16
+ def validate_thread_options(options: ThreadOptions, resuming: bool) -> None:
17
+ if options.permission is not None and options.permission not in _PERMISSIONS:
18
+ raise MCodeInvocationError(
19
+ f"permission must be smart, full or off; received {options.permission!r}."
20
+ )
21
+ if options.mode is not None and options.mode not in _MODES:
22
+ raise MCodeInvocationError(
23
+ f"mode must be standard or lightweight; received {options.mode!r}."
24
+ )
25
+ if resuming and options.mode == "lightweight":
26
+ raise MCodeInvocationError(
27
+ 'mode "lightweight" requires a new Thread; it cannot be resumed.'
28
+ )
29
+ if options.prompt_mode is not None and options.prompt_mode not in _PROMPT_MODES:
30
+ raise MCodeInvocationError(
31
+ f"prompt_mode must be tui, coding or work; received {options.prompt_mode!r}."
32
+ )
33
+
34
+
35
+ def _positive_int(value: object) -> bool:
36
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
37
+
38
+
39
+ def validate_run_options(options: RunOptions) -> None:
40
+ if options.timeout_ms is not None and (
41
+ not _positive_int(options.timeout_ms) or options.timeout_ms > MAX_TIMEOUT_MS
42
+ ):
43
+ raise MCodeInvocationError(
44
+ f"timeout_ms must be a positive integer no greater than {MAX_TIMEOUT_MS}."
45
+ )
46
+ if options.max_steps is not None and not _positive_int(options.max_steps):
47
+ raise MCodeInvocationError("max_steps must be a positive integer.")
48
+
49
+
50
+ def build_exec_args(
51
+ *,
52
+ thread: ThreadOptions,
53
+ session_id: str | None,
54
+ run: RunOptions,
55
+ config_path: str | None = None,
56
+ system_prompt_file: str | None = None,
57
+ append_system_prompt_file: str | None = None,
58
+ ) -> list[str]:
59
+ """The prompt travels over stdin and system prompts through temp files; values use the
60
+ ``--flag=value`` form so a value starting with ``-`` is never parsed as a flag."""
61
+ args = [
62
+ "exec",
63
+ "--output-format=stream-json",
64
+ "--input=-",
65
+ "--input-format=json",
66
+ f"--cwd={os.path.abspath(thread.cwd or os.getcwd())}",
67
+ ]
68
+
69
+ def push(flag: str, value: str | None) -> None:
70
+ if value is not None:
71
+ args.append(f"{flag}={value}")
72
+
73
+ push("--model", thread.model)
74
+ push("--effort", thread.effort)
75
+ push("--permission", thread.permission)
76
+ push("--prompt-mode", thread.prompt_mode)
77
+ push("--config", config_path)
78
+ if session_id is None:
79
+ push("--mode", thread.mode)
80
+ else:
81
+ push("--session", session_id)
82
+ push("--system-prompt-file", system_prompt_file)
83
+ push("--append-system-prompt-file", append_system_prompt_file)
84
+ for file in run.files:
85
+ push("--file", file)
86
+ if run.timeout_ms is not None:
87
+ push("--timeout", f"{run.timeout_ms}ms")
88
+ if run.max_steps is not None:
89
+ push("--max-steps", str(run.max_steps))
90
+ return args
minimax_code/_cli.py ADDED
@@ -0,0 +1,205 @@
1
+ """Locates and version-checks the ``mcode`` CLI. Mirrors ``packages/code-sdk/src/cli-resolver.ts``
2
+ and the Windows launcher handling of ``project-bin.ts``: the SDK never starts the CLI through a
3
+ shell, so Windows ``.cmd`` launchers are followed to the JS entry and Node runtime they run.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import os
10
+ import re
11
+ import shutil
12
+ import sys
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+
16
+ from .errors import MCODE_INSTALL_HINT, MCodeNotFoundError, MCodeVersionError
17
+
18
+ MIN_CLI_VERSION = (0, 6, 3)
19
+ _JS_SUFFIXES = (".js", ".mjs", ".cjs")
20
+ #: Windows files that CreateProcess runs directly, without cmd.exe.
21
+ _WINDOWS_NATIVE = (".exe", ".com")
22
+ #: Lines of the Windows root launcher written by the official installer and CLI self-update.
23
+ _VERSIONED_POINTER_LINE = 'SET /P MCODE_RELEASE=<"%~dp0current"'
24
+ _VERSIONED_CALL_LINE = 'CALL "%~dp0releases\\%MCODE_RELEASE%\\.mcode-launcher.cmd"'
25
+ #: Same release-key rule the root launcher enforces before following the pointer.
26
+ _RELEASE_KEY = re.compile(r"[0-9A-Za-z][0-9A-Za-z._-]*")
27
+ #: JS entry in an npm / pnpm ``.cmd`` shim or an installer release launcher.
28
+ _CMD_SHIM_ENTRY = re.compile(r'"%~?dp0%?\\?([^"%]+\.(?:c|m)?js)"', re.IGNORECASE)
29
+ #: JS entry in an npm / pnpm POSIX shell or PowerShell shim.
30
+ _SHELL_SHIM_ENTRY = re.compile(r'"\$basedir[\\/]([^"$]+\.(?:c|m)?js)"')
31
+ #: The pinned runtime of an installer release launcher: ``"<node>" "%~dp0<entry>" %*``.
32
+ _CMD_RUNTIME = re.compile(r'^"([^"%]+)"[ \t]+"%~dp0', re.MULTILINE)
33
+ _SHIM_HEAD_BYTES = 4096
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ResolvedCli:
38
+ """How to start the CLI."""
39
+
40
+ path: str
41
+ """The executable, or the JS entry that runs with Node."""
42
+ node: str | None = None
43
+ """Node runtime for a JS entry; ``None`` uses ``node`` from ``PATH``."""
44
+ launcher: str | None = None
45
+ """Windows installer root launcher. Its release pointer is followed again before every run, so
46
+ a CLI self-update between Turns is picked up."""
47
+
48
+ def command(self, args: list[str]) -> list[str]:
49
+ if self.launcher is not None:
50
+ current = _follow_versioned_launcher(self.launcher)
51
+ if current is None:
52
+ raise MCodeNotFoundError(
53
+ f"{self.launcher} no longer points to a usable release. "
54
+ "Pass the CLI's JS entry as MCodeOptions(bin=...)."
55
+ )
56
+ return current.command(args)
57
+ if self.path.lower().endswith(_JS_SUFFIXES):
58
+ return [self.node or shutil.which("node") or "node", self.path, *args]
59
+ return [self.path, *args]
60
+
61
+
62
+ def resolve_cli(
63
+ bin: str | None, env: Mapping[str, str], platform: str = sys.platform
64
+ ) -> ResolvedCli:
65
+ """Explicit ``bin`` → ``$MCODE_BIN`` → ``mcode`` on ``PATH``."""
66
+ explicit = bin or _env(env, "MCODE_BIN", platform) or None
67
+ if explicit:
68
+ path = os.path.abspath(os.path.expanduser(explicit))
69
+ if not os.path.isfile(path):
70
+ source = "bin" if bin else "MCODE_BIN"
71
+ raise MCodeNotFoundError(f"mcode CLI from {source} does not exist: {path}")
72
+ return _from_executable(path, platform)
73
+ found = shutil.which("mcode", path=_env(env, "PATH", platform) or os.defpath)
74
+ if not found:
75
+ raise MCodeNotFoundError(
76
+ "Could not find the mcode CLI on PATH. Install it with "
77
+ f"`{MCODE_INSTALL_HINT}`, set MCODE_BIN, or pass MCodeOptions(bin=...)."
78
+ )
79
+ return _from_executable(found, platform)
80
+
81
+
82
+ def _from_executable(path: str, platform: str) -> ResolvedCli:
83
+ extension = os.path.splitext(path)[1].lower()
84
+ if extension in _JS_SUFFIXES or platform != "win32" or extension in _WINDOWS_NATIVE:
85
+ return ResolvedCli(path)
86
+ # A batch file or shell shim would need cmd.exe and its quoting rules, and cancelling it would
87
+ # stop only cmd.exe and leave the CLI running. Run the JS entry behind it instead.
88
+ try:
89
+ head = _read_head(path)
90
+ except OSError:
91
+ head = ""
92
+ if extension in (".cmd", ".bat"):
93
+ resolved = resolve_cmd_launcher(head, path)
94
+ else:
95
+ entry = _parse_shim(_SHELL_SHIM_ENTRY, head, path)
96
+ resolved = ResolvedCli(entry, node=_adjacent_node(path)) if entry else None
97
+ if resolved is None:
98
+ raise MCodeNotFoundError(
99
+ f"Found {path}, but could not determine the JS entry it runs. "
100
+ "Pass the CLI's JS entry as MCodeOptions(bin=...) or MCODE_BIN."
101
+ )
102
+ return resolved
103
+
104
+
105
+ def resolve_cmd_launcher(content: str, launcher: str) -> ResolvedCli | None:
106
+ """Resolves a Windows ``.cmd`` launcher: an npm / pnpm shim, or the installer's root
107
+ launcher, whose release is looked up again before every run."""
108
+ entry = _parse_shim(_CMD_SHIM_ENTRY, content, launcher)
109
+ if entry:
110
+ return ResolvedCli(entry, node=_adjacent_node(launcher))
111
+ versioned = _VERSIONED_POINTER_LINE in content and _VERSIONED_CALL_LINE in content
112
+ if versioned and _follow_versioned_launcher(launcher) is not None:
113
+ return ResolvedCli(launcher, launcher=launcher)
114
+ return None
115
+
116
+
117
+ def _follow_versioned_launcher(launcher: str) -> ResolvedCli | None:
118
+ """``<prefix>\\mcode.cmd`` reads the release key from ``<prefix>\\current`` and calls
119
+ ``<prefix>\\releases\\<release>\\.mcode-launcher.cmd``, which runs a pinned Node runtime."""
120
+ prefix = os.path.dirname(launcher)
121
+ try:
122
+ with open(os.path.join(prefix, "current"), encoding="utf-8") as handle:
123
+ release = handle.read().strip()
124
+ if not _RELEASE_KEY.fullmatch(release):
125
+ return None
126
+ release_launcher = os.path.join(prefix, "releases", release, ".mcode-launcher.cmd")
127
+ content = _read_head(release_launcher)
128
+ except OSError:
129
+ return None
130
+ entry = _parse_shim(_CMD_SHIM_ENTRY, content, release_launcher)
131
+ if entry is None:
132
+ return None
133
+ runtime = _CMD_RUNTIME.search(content)
134
+ node = runtime.group(1) if runtime and os.path.isfile(runtime.group(1)) else None
135
+ return ResolvedCli(entry, node=node)
136
+
137
+
138
+ def _parse_shim(pattern: re.Pattern[str], content: str, shim: str) -> str | None:
139
+ match = pattern.search(content)
140
+ if not match:
141
+ return None
142
+ parts = [part for part in re.split(r"[\\/]", match.group(1)) if part]
143
+ entry = os.path.normpath(os.path.join(os.path.dirname(shim), *parts))
144
+ return entry if os.path.isfile(entry) else None
145
+
146
+
147
+ def _adjacent_node(shim: str) -> str | None:
148
+ """npm shims prefer a ``node.exe`` next to them over ``PATH``."""
149
+ candidate = os.path.join(os.path.dirname(shim), "node.exe")
150
+ return candidate if os.path.isfile(candidate) else None
151
+
152
+
153
+ def _read_head(path: str) -> str:
154
+ with open(path, "rb") as handle:
155
+ return handle.read(_SHIM_HEAD_BYTES).decode("utf-8", "replace")
156
+
157
+
158
+ def _env(env: Mapping[str, str], key: str, platform: str) -> str | None:
159
+ """Environment names are case-insensitive on Windows (``Path``)."""
160
+ if platform != "win32":
161
+ return env.get(key)
162
+ return next((value for name, value in env.items() if name.upper() == key), None)
163
+
164
+
165
+ def parse_version(text: str) -> tuple[int, int, int] | None:
166
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", text)
167
+ if not match:
168
+ return None
169
+ major, minor, patch = (int(part) for part in match.groups())
170
+ return (major, minor, patch)
171
+
172
+
173
+ async def check_cli_version(cli: ResolvedCli, env: Mapping[str, str]) -> str:
174
+ try:
175
+ proc = await asyncio.create_subprocess_exec(
176
+ *cli.command(["--version"]),
177
+ stdin=asyncio.subprocess.DEVNULL,
178
+ stdout=asyncio.subprocess.PIPE,
179
+ stderr=asyncio.subprocess.PIPE,
180
+ env=dict(env),
181
+ )
182
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=30)
183
+ except (OSError, asyncio.TimeoutError) as error:
184
+ raise MCodeNotFoundError(f"Could not run `{cli.path} --version`: {error}") from error
185
+ if proc.returncode != 0:
186
+ raise MCodeVersionError(
187
+ f"`mcode --version` failed ({proc.returncode}); cannot verify CLI compatibility.",
188
+ exit_code=proc.returncode,
189
+ stderr=err.decode("utf-8", "replace"),
190
+ )
191
+ text = out.decode("utf-8", "replace")
192
+ version = parse_version(text)
193
+ if version is None:
194
+ raise MCodeVersionError(f"Could not read the mcode version from: {text.strip()!r}.")
195
+ if version < MIN_CLI_VERSION:
196
+ minimum = ".".join(map(str, MIN_CLI_VERSION))
197
+ found = ".".join(map(str, version))
198
+ raise MCodeVersionError(
199
+ f"mcode {found} is older than the minimum {minimum} required by minimax-code-sdk. "
200
+ f"Upgrade with `{MCODE_INSTALL_HINT}`."
201
+ )
202
+ return ".".join(map(str, version))
203
+
204
+
205
+ __all__ = ["ResolvedCli", "check_cli_version", "resolve_cli", "resolve_cmd_launcher"]
minimax_code/_items.py ADDED
@@ -0,0 +1,71 @@
1
+ """Folds ``item.*`` events into SDK items. Mirrors ``packages/code-sdk/src/items.ts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from .types import AgentMessageItem, ReasoningItem, ThreadEvent, ThreadItem, ToolCallItem
10
+
11
+
12
+ @dataclass
13
+ class _State:
14
+ type: str
15
+ text: str = ""
16
+ tool_call: dict[str, Any] | None = None
17
+ completed: bool = False
18
+ snapshot: str = ""
19
+
20
+
21
+ class ItemAccumulator:
22
+ def __init__(self) -> None:
23
+ self._states: dict[str, _State] = {}
24
+
25
+ def apply(self, event: ThreadEvent) -> bool:
26
+ """Applies one event. Returns False for a duplicate snapshot ``item.updated``."""
27
+ if event["type"] not in ("item.started", "item.updated", "item.completed"):
28
+ return True
29
+ item = event.get("item")
30
+ if item is None:
31
+ return True
32
+ delta = item.get("contentDelta")
33
+ snapshot = json.dumps(item, sort_keys=True) if delta is None else ""
34
+ state = self._states.get(item["id"])
35
+ if (
36
+ state is not None
37
+ and event["type"] == "item.updated"
38
+ and snapshot
39
+ and state.snapshot == snapshot
40
+ and not state.completed
41
+ ):
42
+ return False
43
+ if state is None:
44
+ state = _State(type=item["type"])
45
+ self._states[item["id"]] = state
46
+ state.snapshot = snapshot
47
+ if delta is not None:
48
+ state.text += delta
49
+ if "content" in item:
50
+ state.text = item["content"]
51
+ if "toolCall" in item:
52
+ state.tool_call = item["toolCall"]
53
+ if event["type"] == "item.completed":
54
+ state.completed = True
55
+ return True
56
+
57
+ def items(self) -> list[ThreadItem]:
58
+ out: list[ThreadItem] = []
59
+ for item_id, s in self._states.items():
60
+ incomplete = not s.completed
61
+ if s.type == "agent_message":
62
+ out.append(AgentMessageItem(id=item_id, text=s.text, incomplete=incomplete))
63
+ elif s.type == "reasoning":
64
+ out.append(ReasoningItem(id=item_id, text=s.text, incomplete=incomplete))
65
+ elif s.type == "tool_call":
66
+ out.append(
67
+ ToolCallItem(
68
+ id=item_id, tool_call=s.tool_call or {"id": item_id}, incomplete=incomplete
69
+ )
70
+ )
71
+ return out
@@ -0,0 +1,56 @@
1
+ """Parses stdout lines. Mirrors ``packages/code-sdk/src/protocol.ts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, cast
7
+
8
+ from .errors import MCodeProtocolError, MCodeVersionError
9
+ from .types import ThreadEvent
10
+
11
+ SUPPORTED_SCHEMA_VERSION = 1
12
+
13
+
14
+ def _truncate(text: str) -> str:
15
+ return text if len(text) <= 200 else text[:200] + "…"
16
+
17
+
18
+ def parse_event_line(line: str) -> ThreadEvent | None:
19
+ text = line.strip()
20
+ if not text:
21
+ return None
22
+ try:
23
+ value: Any = json.loads(text)
24
+ except json.JSONDecodeError as error:
25
+ raise MCodeProtocolError(f"mcode stdout is not JSON: {_truncate(text)}") from error
26
+ if not isinstance(value, dict):
27
+ raise MCodeProtocolError(f"mcode stdout line is not a JSON object: {_truncate(text)}")
28
+ if value.get("schemaVersion") != SUPPORTED_SCHEMA_VERSION:
29
+ raise MCodeVersionError(
30
+ f"Unsupported mcode event schemaVersion {value.get('schemaVersion')!r}; "
31
+ f"this SDK supports {SUPPORTED_SCHEMA_VERSION}."
32
+ )
33
+ if not isinstance(value.get("type"), str) or not isinstance(value.get("sessionId"), str):
34
+ raise MCodeProtocolError(f"mcode event is missing type or sessionId: {_truncate(text)}")
35
+ kind: str = value["type"]
36
+ if kind.startswith("item."):
37
+ item = value.get("item")
38
+ if not (
39
+ isinstance(item, dict)
40
+ and isinstance(item.get("id"), str)
41
+ and isinstance(item.get("type"), str)
42
+ ):
43
+ raise MCodeProtocolError(f"mcode {kind} event has an invalid item: {_truncate(text)}")
44
+ if kind == "exec.completed":
45
+ result = value.get("result")
46
+ if not (
47
+ isinstance(result, dict)
48
+ and result.get("schemaVersion") == SUPPORTED_SCHEMA_VERSION
49
+ and isinstance(result.get("status"), str)
50
+ and isinstance(result.get("sessionId"), str)
51
+ and isinstance(result.get("turnId"), str)
52
+ ):
53
+ raise MCodeProtocolError(
54
+ f"mcode exec.completed has an invalid result: {_truncate(text)}"
55
+ )
56
+ return cast(ThreadEvent, value)
minimax_code/client.py ADDED
@@ -0,0 +1,76 @@
1
+ """Entry point. Mirrors ``packages/code-sdk/src/mcode.ts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import os
8
+
9
+ from ._args import validate_thread_options
10
+ from ._cli import ResolvedCli, check_cli_version, resolve_cli
11
+ from .errors import MCodeInvocationError
12
+ from .thread import Thread, ThreadHost
13
+ from .types import MCodeOptions, ThreadOptions
14
+
15
+
16
+ class MCode:
17
+ """One client resolves and version-checks the CLI once, then starts Threads."""
18
+
19
+ def __init__(self, options: MCodeOptions | None = None) -> None:
20
+ self._options = options or MCodeOptions()
21
+ self._cli: ResolvedCli | None = None
22
+ self._lock: asyncio.Lock | None = None
23
+ config = self._options.config_path
24
+ self._host = ThreadHost(
25
+ config_path=os.path.abspath(config) if config else None,
26
+ child_env=self._child_env,
27
+ prepare_cli=self._prepare_cli,
28
+ )
29
+
30
+ def start_thread(self, options: ThreadOptions | None = None) -> Thread:
31
+ """Starts a new Thread. The Session is created by its first Turn.
32
+
33
+ ``ThreadOptions.cwd`` defaults to the current working directory and is resolved now, so a
34
+ later ``os.chdir()`` does not move the Thread to another workspace.
35
+ """
36
+ options = options or ThreadOptions()
37
+ validate_thread_options(options, resuming=False)
38
+ return Thread(self._host, _with_absolute_cwd(options), None)
39
+
40
+ def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> Thread:
41
+ """Continues an existing Session, for example one persisted from ``thread.id``.
42
+
43
+ Pass the same ``ThreadOptions.cwd`` the Session was created with. When ``cwd`` is omitted
44
+ the current working directory at this call is used, and the CLI rejects a Session whose
45
+ workspace differs (``MCodeRunError`` with "belongs to workspace ...").
46
+ """
47
+ if not isinstance(thread_id, str) or not thread_id.strip():
48
+ raise MCodeInvocationError("resume_thread requires a non-empty Session id.")
49
+ options = options or ThreadOptions()
50
+ validate_thread_options(options, resuming=True)
51
+ return Thread(self._host, _with_absolute_cwd(options), thread_id.strip())
52
+
53
+ def _child_env(self) -> dict[str, str]:
54
+ env = dict(self._options.env if self._options.env is not None else os.environ)
55
+ if self._options.data_dir is not None:
56
+ env["MINIMAX_DATA_DIR"] = os.path.abspath(self._options.data_dir)
57
+ return env
58
+
59
+ async def _prepare_cli(self) -> ResolvedCli:
60
+ if self._cli is not None:
61
+ return self._cli
62
+ if self._lock is None:
63
+ self._lock = asyncio.Lock()
64
+ async with self._lock:
65
+ if self._cli is None:
66
+ env = self._child_env()
67
+ cli = resolve_cli(self._options.bin, env)
68
+ await check_cli_version(cli, env)
69
+ # Only cached on success, so a later run works after installing the CLI.
70
+ self._cli = cli
71
+ return self._cli
72
+
73
+
74
+ def _with_absolute_cwd(options: ThreadOptions) -> ThreadOptions:
75
+ """Pins the workspace when the Thread is created; every Turn of a Session must use it."""
76
+ return dataclasses.replace(options, cwd=os.path.abspath(options.cwd or os.getcwd()))