mindcode 0.2.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.
- mindcode/__init__.py +5 -0
- mindcode/__main__.py +4 -0
- mindcode/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/__pycache__/__main__.cpython-313.pyc +0 -0
- mindcode/__pycache__/_version.cpython-313.pyc +0 -0
- mindcode/__pycache__/config.cpython-313.pyc +0 -0
- mindcode/__pycache__/policy.cpython-313.pyc +0 -0
- mindcode/__pycache__/remote.cpython-313.pyc +0 -0
- mindcode/__pycache__/render.cpython-313.pyc +0 -0
- mindcode/__pycache__/runtime.cpython-313.pyc +0 -0
- mindcode/__pycache__/tasking.cpython-313.pyc +0 -0
- mindcode/__pycache__/terminal_core.cpython-313.pyc +0 -0
- mindcode/_version.py +11 -0
- mindcode/approval.py +105 -0
- mindcode/bench/__init__.py +19 -0
- mindcode/bench/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/fake_tools.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/jsonutil.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/paths.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/predictions.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/report.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/runner.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/schema.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/trace.cpython-313.pyc +0 -0
- mindcode/bench/__pycache__/workspace.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__init__.py +22 -0
- mindcode/bench/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/base.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_bfcl.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_gaia.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/mini_terminal.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/swe_bench.cpython-313.pyc +0 -0
- mindcode/bench/adapters/__pycache__/terminal_bench.cpython-313.pyc +0 -0
- mindcode/bench/adapters/base.py +45 -0
- mindcode/bench/adapters/mini_bfcl.py +18 -0
- mindcode/bench/adapters/mini_gaia.py +18 -0
- mindcode/bench/adapters/mini_terminal.py +18 -0
- mindcode/bench/adapters/swe_bench.py +86 -0
- mindcode/bench/adapters/terminal_bench.py +107 -0
- mindcode/bench/fake_tools.py +73 -0
- mindcode/bench/jsonutil.py +20 -0
- mindcode/bench/paths.py +10 -0
- mindcode/bench/predictions.py +65 -0
- mindcode/bench/report.py +48 -0
- mindcode/bench/runner.py +250 -0
- mindcode/bench/schema.py +78 -0
- mindcode/bench/scorers/__pycache__/base.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/composite.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/exact.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/json_call.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/swe.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/terminal.cpython-313.pyc +0 -0
- mindcode/bench/scorers/__pycache__/terminal_bench.cpython-313.pyc +0 -0
- mindcode/bench/scorers/base.py +22 -0
- mindcode/bench/scorers/composite.py +57 -0
- mindcode/bench/scorers/exact.py +30 -0
- mindcode/bench/scorers/json_call.py +44 -0
- mindcode/bench/scorers/swe.py +29 -0
- mindcode/bench/scorers/terminal.py +43 -0
- mindcode/bench/scorers/terminal_bench.py +25 -0
- mindcode/bench/trace.py +34 -0
- mindcode/bench/workspace.py +127 -0
- mindcode/cli/__init__.py +112 -0
- mindcode/cli/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/__pycache__/_shared.cpython-313.pyc +0 -0
- mindcode/cli/_shared.py +43 -0
- mindcode/cli/commands/__init__.py +1 -0
- mindcode/cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/bench.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/chat.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/config_cmd.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/remote.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/shell.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/status.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/task.cpython-313.pyc +0 -0
- mindcode/cli/commands/__pycache__/terminal.cpython-313.pyc +0 -0
- mindcode/cli/commands/bench.py +365 -0
- mindcode/cli/commands/chat.py +86 -0
- mindcode/cli/commands/config_cmd.py +197 -0
- mindcode/cli/commands/remote.py +146 -0
- mindcode/cli/commands/shell.py +108 -0
- mindcode/cli/commands/status.py +37 -0
- mindcode/cli/commands/task.py +417 -0
- mindcode/cli/commands/terminal.py +124 -0
- mindcode/cli/shell/__init__.py +5 -0
- mindcode/cli/shell/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/completion.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/menu.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/repl.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/slash.cpython-313.pyc +0 -0
- mindcode/cli/shell/__pycache__/startup.cpython-313.pyc +0 -0
- mindcode/cli/shell/completion.py +72 -0
- mindcode/cli/shell/menu.py +88 -0
- mindcode/cli/shell/repl.py +597 -0
- mindcode/cli/shell/slash.py +480 -0
- mindcode/cli/shell/startup.py +55 -0
- mindcode/cli/shell/tui.py +897 -0
- mindcode/config.py +173 -0
- mindcode/mcp.py +205 -0
- mindcode/policy.py +173 -0
- mindcode/remote.py +216 -0
- mindcode/render.py +458 -0
- mindcode/runtime.py +541 -0
- mindcode/skills.py +154 -0
- mindcode/subagents.py +43 -0
- mindcode/tasking.py +281 -0
- mindcode/terminal/__init__.py +1 -0
- mindcode/terminal/__main__.py +17 -0
- mindcode/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- mindcode/terminal/__pycache__/__main__.cpython-313.pyc +0 -0
- mindcode/terminal_core.py +264 -0
- mindcode-0.2.0.dist-info/METADATA +244 -0
- mindcode-0.2.0.dist-info/RECORD +116 -0
- mindcode-0.2.0.dist-info/WHEEL +5 -0
- mindcode-0.2.0.dist-info/entry_points.txt +2 -0
- mindcode-0.2.0.dist-info/top_level.txt +1 -0
mindcode/subagents.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""MindCode ownership boundary for orchestrated master/worker runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mindagent.core import (
|
|
9
|
+
AgentOrchestrator,
|
|
10
|
+
AgentTaskResult,
|
|
11
|
+
CloseReport,
|
|
12
|
+
SessionEnvironment,
|
|
13
|
+
SessionPolicy,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MasterWorkerSystem:
|
|
19
|
+
"""Run MindCode's registered master and close its owned workers."""
|
|
20
|
+
|
|
21
|
+
orchestrator: AgentOrchestrator
|
|
22
|
+
master_agent_id: str = "master"
|
|
23
|
+
|
|
24
|
+
async def run(
|
|
25
|
+
self,
|
|
26
|
+
user_input: str,
|
|
27
|
+
*,
|
|
28
|
+
task_id: str | None = None,
|
|
29
|
+
session_environment: SessionEnvironment | None = None,
|
|
30
|
+
session_policy: SessionPolicy | None = None,
|
|
31
|
+
messages: list[dict[str, Any]] | None = None,
|
|
32
|
+
) -> AgentTaskResult:
|
|
33
|
+
return await self.orchestrator.run_agent(
|
|
34
|
+
self.master_agent_id,
|
|
35
|
+
user_input,
|
|
36
|
+
task_id=task_id,
|
|
37
|
+
session_environment=session_environment,
|
|
38
|
+
session_policy=session_policy,
|
|
39
|
+
messages=messages,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def close(self) -> CloseReport:
|
|
43
|
+
return await self.orchestrator.close(close_workers=True)
|
mindcode/tasking.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mindagent.core import AgentEvent, LocalTaskMemoryStore
|
|
14
|
+
from mindagent.core.artifact import (
|
|
15
|
+
ensure_artifact_directory,
|
|
16
|
+
open_artifact_text,
|
|
17
|
+
read_artifact_text,
|
|
18
|
+
validate_artifact_path,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .config import CONFIG_DIR
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TASKS_DIR = CONFIG_DIR / "tasks"
|
|
25
|
+
_TASK_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskStatus(str, Enum):
|
|
29
|
+
CREATED = "created"
|
|
30
|
+
RUNNING = "running"
|
|
31
|
+
PARTIAL = "partial"
|
|
32
|
+
COMPLETED = "completed"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
CANCELLED = "cancelled"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TaskRecord:
|
|
39
|
+
task_id: str
|
|
40
|
+
status: str
|
|
41
|
+
goal: str
|
|
42
|
+
workspace: str
|
|
43
|
+
provider: str
|
|
44
|
+
model: str | None
|
|
45
|
+
approve_mode: str
|
|
46
|
+
created_at: float
|
|
47
|
+
started_at: float | None = None
|
|
48
|
+
completed_at: float | None = None
|
|
49
|
+
run_id: str | None = None
|
|
50
|
+
final_answer: str | None = None
|
|
51
|
+
error: str | None = None
|
|
52
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
pid: int | None = None
|
|
54
|
+
background: bool = False
|
|
55
|
+
max_steps: int = 50
|
|
56
|
+
total_timeout_s: float | None = 1800.0
|
|
57
|
+
allow_dangerous: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TaskStore:
|
|
61
|
+
def __init__(self, root: Path = TASKS_DIR):
|
|
62
|
+
root_path = Path(root).expanduser()
|
|
63
|
+
ensure_artifact_directory(root_path)
|
|
64
|
+
self.root = root_path.resolve()
|
|
65
|
+
|
|
66
|
+
def task_dir(self, task_id: str) -> Path:
|
|
67
|
+
task_id = _canonical_task_id(task_id)
|
|
68
|
+
path = self.root / task_id
|
|
69
|
+
validate_artifact_path(self.root, path)
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
def _task_file(self, task_id: str, filename: str) -> Path:
|
|
73
|
+
path = self.task_dir(task_id) / filename
|
|
74
|
+
validate_artifact_path(self.root, path)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
def _write_text(self, path: Path, text: str) -> None:
|
|
78
|
+
validate_artifact_path(self.root, path)
|
|
79
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
80
|
+
try:
|
|
81
|
+
with open_artifact_text(temporary, root=self.root) as file:
|
|
82
|
+
file.write(text)
|
|
83
|
+
file.flush()
|
|
84
|
+
os.fsync(file.fileno())
|
|
85
|
+
validate_artifact_path(self.root, path)
|
|
86
|
+
os.replace(temporary, path)
|
|
87
|
+
finally:
|
|
88
|
+
try:
|
|
89
|
+
temporary.unlink()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def create(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
goal: str,
|
|
97
|
+
workspace: Path,
|
|
98
|
+
provider: str,
|
|
99
|
+
model: str | None,
|
|
100
|
+
approve_mode: str,
|
|
101
|
+
background: bool = False,
|
|
102
|
+
max_steps: int = 50,
|
|
103
|
+
total_timeout_s: float | None = 1800.0,
|
|
104
|
+
allow_dangerous: bool = False,
|
|
105
|
+
) -> TaskRecord:
|
|
106
|
+
now = time.time()
|
|
107
|
+
task_id = (
|
|
108
|
+
time.strftime("task-%Y%m%d-%H%M%S", time.localtime(now))
|
|
109
|
+
+ f"-{int(now * 1000) % 1000:03d}"
|
|
110
|
+
)
|
|
111
|
+
record = TaskRecord(
|
|
112
|
+
task_id=task_id,
|
|
113
|
+
status=TaskStatus.CREATED.value,
|
|
114
|
+
goal=goal,
|
|
115
|
+
workspace=str(workspace),
|
|
116
|
+
provider=provider,
|
|
117
|
+
model=model,
|
|
118
|
+
approve_mode=approve_mode,
|
|
119
|
+
created_at=now,
|
|
120
|
+
background=background,
|
|
121
|
+
max_steps=max_steps,
|
|
122
|
+
total_timeout_s=total_timeout_s,
|
|
123
|
+
allow_dangerous=allow_dangerous,
|
|
124
|
+
)
|
|
125
|
+
self.save(record)
|
|
126
|
+
return record
|
|
127
|
+
|
|
128
|
+
def save(self, record: TaskRecord) -> None:
|
|
129
|
+
if not isinstance(record, TaskRecord):
|
|
130
|
+
raise ValueError("record 必须是 TaskRecord")
|
|
131
|
+
record.task_id = _canonical_task_id(record.task_id)
|
|
132
|
+
if record.run_id is not None:
|
|
133
|
+
record.run_id = _canonical_record_text(
|
|
134
|
+
record.run_id,
|
|
135
|
+
"run_id",
|
|
136
|
+
require_nonempty=True,
|
|
137
|
+
)
|
|
138
|
+
path = self.task_dir(record.task_id)
|
|
139
|
+
ensure_artifact_directory(path, root=self.root)
|
|
140
|
+
self._write_text(
|
|
141
|
+
self._task_file(record.task_id, "task.json"),
|
|
142
|
+
json.dumps(asdict(record), ensure_ascii=False, indent=2),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def load(self, task_id: str) -> TaskRecord:
|
|
146
|
+
task_id_text = _canonical_task_id(task_id)
|
|
147
|
+
path = self._task_file(task_id_text, "task.json")
|
|
148
|
+
data = json.loads(read_artifact_text(path, root=self.root))
|
|
149
|
+
if not isinstance(data, dict):
|
|
150
|
+
raise ValueError("task payload 必须是对象")
|
|
151
|
+
record = TaskRecord(**data)
|
|
152
|
+
if _canonical_task_id(record.task_id) != task_id_text:
|
|
153
|
+
raise ValueError("task task_id 不匹配")
|
|
154
|
+
record.task_id = task_id_text
|
|
155
|
+
return record
|
|
156
|
+
|
|
157
|
+
def list(self) -> list[TaskRecord]:
|
|
158
|
+
validate_artifact_path(self.root, self.root)
|
|
159
|
+
if not self.root.exists():
|
|
160
|
+
return []
|
|
161
|
+
records: list[TaskRecord] = []
|
|
162
|
+
for path in sorted(self.root.iterdir(), reverse=True):
|
|
163
|
+
if path.is_symlink() or not path.is_dir():
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
task_path = self._task_file(path.name, "task.json")
|
|
167
|
+
except (OSError, RuntimeError, ValueError):
|
|
168
|
+
continue
|
|
169
|
+
if not task_path.exists():
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
record = TaskRecord(
|
|
173
|
+
**json.loads(read_artifact_text(task_path, root=self.root))
|
|
174
|
+
)
|
|
175
|
+
if _canonical_task_id(record.task_id) != path.name:
|
|
176
|
+
continue
|
|
177
|
+
record.task_id = path.name
|
|
178
|
+
records.append(self.refresh(record))
|
|
179
|
+
except Exception:
|
|
180
|
+
continue
|
|
181
|
+
return records
|
|
182
|
+
|
|
183
|
+
def refresh(self, record: TaskRecord) -> TaskRecord:
|
|
184
|
+
if (
|
|
185
|
+
record.status == TaskStatus.RUNNING.value
|
|
186
|
+
and record.pid is not None
|
|
187
|
+
and not is_process_alive(record.pid)
|
|
188
|
+
):
|
|
189
|
+
record.status = TaskStatus.FAILED.value
|
|
190
|
+
record.completed_at = time.time()
|
|
191
|
+
record.error = "background process exited without finalizing task"
|
|
192
|
+
self.save(record)
|
|
193
|
+
self.write_summary(record)
|
|
194
|
+
return record
|
|
195
|
+
|
|
196
|
+
def write_summary(self, record: TaskRecord) -> None:
|
|
197
|
+
metrics = "\n".join(
|
|
198
|
+
f"- {key}: {value}" for key, value in sorted(record.metrics.items())
|
|
199
|
+
)
|
|
200
|
+
text = (
|
|
201
|
+
f"# {record.task_id}\n\n"
|
|
202
|
+
f"Status: {record.status}\n"
|
|
203
|
+
f"Goal: {record.goal}\n"
|
|
204
|
+
f"Workspace: {record.workspace}\n\n"
|
|
205
|
+
"## Result\n\n"
|
|
206
|
+
f"{record.final_answer or record.error or ''}\n\n"
|
|
207
|
+
"## Metrics\n\n"
|
|
208
|
+
f"{metrics}\n"
|
|
209
|
+
)
|
|
210
|
+
path = self.task_dir(record.task_id)
|
|
211
|
+
ensure_artifact_directory(path, root=self.root)
|
|
212
|
+
self._write_text(
|
|
213
|
+
self._task_file(record.task_id, "summary.md"),
|
|
214
|
+
text,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TaskEventRecorder:
|
|
219
|
+
def __init__(self, store: TaskStore, task_id: str):
|
|
220
|
+
self.memory_store = LocalTaskMemoryStore(
|
|
221
|
+
store.task_dir(task_id)
|
|
222
|
+
)
|
|
223
|
+
self.agent_events = 0
|
|
224
|
+
self.tool_calls = 0
|
|
225
|
+
|
|
226
|
+
async def __call__(self, event: AgentEvent) -> None:
|
|
227
|
+
self.agent_events += 1
|
|
228
|
+
if event.event_type.value == "action_started":
|
|
229
|
+
self.tool_calls += 1
|
|
230
|
+
await self.memory_store.append_event(event)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def is_process_alive(pid: int) -> bool:
|
|
234
|
+
try:
|
|
235
|
+
os.kill(pid, 0)
|
|
236
|
+
except ProcessLookupError:
|
|
237
|
+
return False
|
|
238
|
+
except PermissionError:
|
|
239
|
+
return True
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _canonical_task_id(task_id: Any) -> str:
|
|
244
|
+
if not isinstance(task_id, str):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"task_id 只能包含字母、数字、点、下划线和连字符"
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
value = str.__str__(task_id)
|
|
250
|
+
length = str.__len__(value)
|
|
251
|
+
except BaseException as exc:
|
|
252
|
+
raise ValueError("task_id 无法解析") from exc
|
|
253
|
+
if (
|
|
254
|
+
type(value) is not str
|
|
255
|
+
or length == 0
|
|
256
|
+
or value in {".", ".."}
|
|
257
|
+
or _TASK_ID_PATTERN.fullmatch(value) is None
|
|
258
|
+
):
|
|
259
|
+
raise ValueError(
|
|
260
|
+
"task_id 只能包含字母、数字、点、下划线和连字符"
|
|
261
|
+
)
|
|
262
|
+
return value
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _canonical_record_text(
|
|
266
|
+
value: Any,
|
|
267
|
+
name: str,
|
|
268
|
+
*,
|
|
269
|
+
require_nonempty: bool = False,
|
|
270
|
+
) -> str:
|
|
271
|
+
if not isinstance(value, str):
|
|
272
|
+
raise ValueError(f"{name} 必须是字符串")
|
|
273
|
+
try:
|
|
274
|
+
normalized = str.__str__(value)
|
|
275
|
+
except BaseException as exc:
|
|
276
|
+
raise ValueError(f"{name} 无法解析") from exc
|
|
277
|
+
if type(normalized) is not str:
|
|
278
|
+
raise ValueError(f"{name} 必须是字符串")
|
|
279
|
+
if require_nonempty and str.__len__(normalized) == 0:
|
|
280
|
+
raise ValueError(f"{name} 必须是非空字符串")
|
|
281
|
+
return normalized
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Terminal worker package."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..terminal_core import run_worker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> int:
|
|
10
|
+
if len(sys.argv) != 4 or sys.argv[1] != "_worker":
|
|
11
|
+
print("usage: python -m mindcode.terminal _worker <terminal_id> <root>")
|
|
12
|
+
return 2
|
|
13
|
+
return run_worker(sys.argv[2], Path(sys.argv[3]))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
raise SystemExit(main())
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pty
|
|
6
|
+
import errno
|
|
7
|
+
import select
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from .config import CONFIG_DIR
|
|
15
|
+
from .tasking import is_process_alive
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TERMINALS_DIR = CONFIG_DIR / "terminals"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TerminalRecord:
|
|
23
|
+
terminal_id: str
|
|
24
|
+
status: str
|
|
25
|
+
cwd: str
|
|
26
|
+
shell: str
|
|
27
|
+
pid: int | None
|
|
28
|
+
created_at: float
|
|
29
|
+
updated_at: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TerminalStore:
|
|
33
|
+
def __init__(self, root: Path = TERMINALS_DIR):
|
|
34
|
+
self.root = root
|
|
35
|
+
|
|
36
|
+
def terminal_dir(self, terminal_id: str) -> Path:
|
|
37
|
+
return self.root / terminal_id
|
|
38
|
+
|
|
39
|
+
def create_record(self, *, cwd: Path, shell: str) -> TerminalRecord:
|
|
40
|
+
now = time.time()
|
|
41
|
+
terminal_id = (
|
|
42
|
+
time.strftime("term-%Y%m%d-%H%M%S", time.localtime(now))
|
|
43
|
+
+ f"-{int(now * 1000) % 1000:03d}"
|
|
44
|
+
)
|
|
45
|
+
record = TerminalRecord(
|
|
46
|
+
terminal_id=terminal_id,
|
|
47
|
+
status="created",
|
|
48
|
+
cwd=str(cwd),
|
|
49
|
+
shell=shell,
|
|
50
|
+
pid=None,
|
|
51
|
+
created_at=now,
|
|
52
|
+
updated_at=now,
|
|
53
|
+
)
|
|
54
|
+
self.save(record)
|
|
55
|
+
return record
|
|
56
|
+
|
|
57
|
+
def save(self, record: TerminalRecord) -> None:
|
|
58
|
+
path = self.terminal_dir(record.terminal_id)
|
|
59
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
record.updated_at = time.time()
|
|
61
|
+
(path / "terminal.json").write_text(
|
|
62
|
+
json.dumps(asdict(record), ensure_ascii=False, indent=2),
|
|
63
|
+
encoding="utf-8",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def load(self, terminal_id: str) -> TerminalRecord:
|
|
67
|
+
data = json.loads(
|
|
68
|
+
(self.terminal_dir(terminal_id) / "terminal.json").read_text(
|
|
69
|
+
encoding="utf-8"
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return TerminalRecord(**data)
|
|
73
|
+
|
|
74
|
+
def list(self) -> list[TerminalRecord]:
|
|
75
|
+
if not self.root.exists():
|
|
76
|
+
return []
|
|
77
|
+
records: list[TerminalRecord] = []
|
|
78
|
+
for path in sorted(self.root.iterdir(), reverse=True):
|
|
79
|
+
record_path = path / "terminal.json"
|
|
80
|
+
if not record_path.exists():
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
records.append(self.refresh(TerminalRecord(**json.loads(record_path.read_text()))))
|
|
84
|
+
except Exception:
|
|
85
|
+
continue
|
|
86
|
+
return records
|
|
87
|
+
|
|
88
|
+
def refresh(self, record: TerminalRecord) -> TerminalRecord:
|
|
89
|
+
if record.status == "running" and record.pid is not None:
|
|
90
|
+
if not is_process_alive(record.pid):
|
|
91
|
+
record.status = "exited"
|
|
92
|
+
self.save(record)
|
|
93
|
+
return record
|
|
94
|
+
|
|
95
|
+
def log_path(self, terminal_id: str) -> Path:
|
|
96
|
+
return self.terminal_dir(terminal_id) / "terminal.log"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TerminalManager:
|
|
100
|
+
def __init__(self, store: TerminalStore | None = None):
|
|
101
|
+
self.store = store or TerminalStore()
|
|
102
|
+
|
|
103
|
+
def start(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
cwd: Path,
|
|
107
|
+
shell: str | None = None,
|
|
108
|
+
) -> TerminalRecord:
|
|
109
|
+
cwd = cwd.expanduser().resolve()
|
|
110
|
+
if not cwd.is_dir():
|
|
111
|
+
raise ValueError(f"cwd 不存在或不是目录: {cwd}")
|
|
112
|
+
shell_path = shell or os.environ.get("SHELL") or "/bin/bash"
|
|
113
|
+
record = self.store.create_record(cwd=cwd, shell=shell_path)
|
|
114
|
+
term_dir = self.store.terminal_dir(record.terminal_id)
|
|
115
|
+
input_path = term_dir / "input.fifo"
|
|
116
|
+
os.mkfifo(input_path)
|
|
117
|
+
|
|
118
|
+
cmd = [
|
|
119
|
+
sys.executable,
|
|
120
|
+
"-m",
|
|
121
|
+
"mindcode.terminal",
|
|
122
|
+
"_worker",
|
|
123
|
+
record.terminal_id,
|
|
124
|
+
str(self.store.root),
|
|
125
|
+
]
|
|
126
|
+
env = os.environ.copy()
|
|
127
|
+
here = Path(__file__).resolve()
|
|
128
|
+
source_paths = [str(here.parents[1])]
|
|
129
|
+
repo_src = here.parents[3] / "src"
|
|
130
|
+
if repo_src.is_dir():
|
|
131
|
+
source_paths.append(str(repo_src))
|
|
132
|
+
env["PYTHONPATH"] = (
|
|
133
|
+
os.pathsep.join(source_paths)
|
|
134
|
+
if not env.get("PYTHONPATH")
|
|
135
|
+
else os.pathsep.join(source_paths) + os.pathsep + env["PYTHONPATH"]
|
|
136
|
+
)
|
|
137
|
+
log_path = term_dir / "worker.log"
|
|
138
|
+
with log_path.open("ab") as log:
|
|
139
|
+
process = subprocess.Popen(
|
|
140
|
+
cmd,
|
|
141
|
+
stdout=log,
|
|
142
|
+
stderr=subprocess.STDOUT,
|
|
143
|
+
cwd=cwd,
|
|
144
|
+
env=env,
|
|
145
|
+
start_new_session=True,
|
|
146
|
+
)
|
|
147
|
+
record.pid = process.pid
|
|
148
|
+
record.status = "running"
|
|
149
|
+
self.store.save(record)
|
|
150
|
+
return record
|
|
151
|
+
|
|
152
|
+
def send(self, terminal_id: str, text: str, *, newline: bool = True) -> None:
|
|
153
|
+
record = self.store.refresh(self.store.load(terminal_id))
|
|
154
|
+
if record.status != "running":
|
|
155
|
+
raise RuntimeError(f"terminal 当前不是 running: {record.status}")
|
|
156
|
+
payload = text + ("\n" if newline else "")
|
|
157
|
+
fifo_path = self.store.terminal_dir(terminal_id) / "input.fifo"
|
|
158
|
+
deadline = time.time() + 5
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
fd = os.open(fifo_path, os.O_WRONLY | os.O_NONBLOCK)
|
|
162
|
+
break
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
if exc.errno != errno.ENXIO or time.time() >= deadline:
|
|
165
|
+
worker_log = self.store.terminal_dir(terminal_id) / "worker.log"
|
|
166
|
+
detail = ""
|
|
167
|
+
if worker_log.exists():
|
|
168
|
+
detail = worker_log.read_text(
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
errors="replace",
|
|
171
|
+
)[-1000:]
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"terminal worker 未就绪: {detail}"
|
|
174
|
+
) from exc
|
|
175
|
+
time.sleep(0.1)
|
|
176
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fifo:
|
|
177
|
+
fifo.write(payload)
|
|
178
|
+
|
|
179
|
+
def close(self, terminal_id: str) -> TerminalRecord:
|
|
180
|
+
record = self.store.refresh(self.store.load(terminal_id))
|
|
181
|
+
if record.status == "running" and record.pid is not None:
|
|
182
|
+
try:
|
|
183
|
+
os.killpg(record.pid, signal.SIGTERM)
|
|
184
|
+
except ProcessLookupError:
|
|
185
|
+
pass
|
|
186
|
+
except PermissionError:
|
|
187
|
+
try:
|
|
188
|
+
os.kill(record.pid, signal.SIGTERM)
|
|
189
|
+
except ProcessLookupError:
|
|
190
|
+
pass
|
|
191
|
+
record.status = "closed"
|
|
192
|
+
self.store.save(record)
|
|
193
|
+
return record
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run_worker(terminal_id: str, root: Path) -> int:
|
|
197
|
+
store = TerminalStore(root)
|
|
198
|
+
record = store.load(terminal_id)
|
|
199
|
+
term_dir = store.terminal_dir(terminal_id)
|
|
200
|
+
input_path = term_dir / "input.fifo"
|
|
201
|
+
log_path = store.log_path(terminal_id)
|
|
202
|
+
|
|
203
|
+
pid, fd = pty.fork()
|
|
204
|
+
if pid == 0:
|
|
205
|
+
os.chdir(record.cwd)
|
|
206
|
+
os.execv(record.shell, [record.shell])
|
|
207
|
+
|
|
208
|
+
with log_path.open("ab", buffering=0) as log:
|
|
209
|
+
fifo_fd = os.open(input_path, os.O_RDWR | os.O_NONBLOCK)
|
|
210
|
+
try:
|
|
211
|
+
while True:
|
|
212
|
+
ready, _, _ = select.select([fd, fifo_fd], [], [], 0.2)
|
|
213
|
+
if fd in ready:
|
|
214
|
+
try:
|
|
215
|
+
data = os.read(fd, 4096)
|
|
216
|
+
except OSError:
|
|
217
|
+
break
|
|
218
|
+
if not data:
|
|
219
|
+
break
|
|
220
|
+
log.write(data)
|
|
221
|
+
if fifo_fd in ready:
|
|
222
|
+
data = os.read(fifo_fd, 4096)
|
|
223
|
+
if data:
|
|
224
|
+
os.write(fd, data)
|
|
225
|
+
try:
|
|
226
|
+
finished, status = os.waitpid(pid, os.WNOHANG)
|
|
227
|
+
except ChildProcessError:
|
|
228
|
+
break
|
|
229
|
+
if finished == pid:
|
|
230
|
+
break
|
|
231
|
+
finally:
|
|
232
|
+
os.close(fifo_fd)
|
|
233
|
+
os.close(fd)
|
|
234
|
+
|
|
235
|
+
record = store.load(terminal_id)
|
|
236
|
+
if record.status == "running":
|
|
237
|
+
record.status = "exited"
|
|
238
|
+
store.save(record)
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def tail_text(path: Path, lines: int) -> str:
|
|
243
|
+
if not path.exists():
|
|
244
|
+
return ""
|
|
245
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
246
|
+
return "\n".join(content.splitlines()[-lines:])
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_ghostty_open_argv(
|
|
250
|
+
*,
|
|
251
|
+
cwd: Path,
|
|
252
|
+
command: str | None = None,
|
|
253
|
+
app_path: str = "/Applications/Ghostty.app",
|
|
254
|
+
) -> list[str]:
|
|
255
|
+
argv = [
|
|
256
|
+
"open",
|
|
257
|
+
"-na",
|
|
258
|
+
app_path,
|
|
259
|
+
"--args",
|
|
260
|
+
f"--working-directory={cwd.expanduser().resolve()}",
|
|
261
|
+
]
|
|
262
|
+
if command:
|
|
263
|
+
argv.extend(["-e", command])
|
|
264
|
+
return argv
|