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.
Files changed (116) hide show
  1. mindcode/__init__.py +5 -0
  2. mindcode/__main__.py +4 -0
  3. mindcode/__pycache__/__init__.cpython-313.pyc +0 -0
  4. mindcode/__pycache__/__main__.cpython-313.pyc +0 -0
  5. mindcode/__pycache__/_version.cpython-313.pyc +0 -0
  6. mindcode/__pycache__/config.cpython-313.pyc +0 -0
  7. mindcode/__pycache__/policy.cpython-313.pyc +0 -0
  8. mindcode/__pycache__/remote.cpython-313.pyc +0 -0
  9. mindcode/__pycache__/render.cpython-313.pyc +0 -0
  10. mindcode/__pycache__/runtime.cpython-313.pyc +0 -0
  11. mindcode/__pycache__/tasking.cpython-313.pyc +0 -0
  12. mindcode/__pycache__/terminal_core.cpython-313.pyc +0 -0
  13. mindcode/_version.py +11 -0
  14. mindcode/approval.py +105 -0
  15. mindcode/bench/__init__.py +19 -0
  16. mindcode/bench/__pycache__/__init__.cpython-313.pyc +0 -0
  17. mindcode/bench/__pycache__/fake_tools.cpython-313.pyc +0 -0
  18. mindcode/bench/__pycache__/jsonutil.cpython-313.pyc +0 -0
  19. mindcode/bench/__pycache__/paths.cpython-313.pyc +0 -0
  20. mindcode/bench/__pycache__/predictions.cpython-313.pyc +0 -0
  21. mindcode/bench/__pycache__/report.cpython-313.pyc +0 -0
  22. mindcode/bench/__pycache__/runner.cpython-313.pyc +0 -0
  23. mindcode/bench/__pycache__/schema.cpython-313.pyc +0 -0
  24. mindcode/bench/__pycache__/trace.cpython-313.pyc +0 -0
  25. mindcode/bench/__pycache__/workspace.cpython-313.pyc +0 -0
  26. mindcode/bench/adapters/__init__.py +22 -0
  27. mindcode/bench/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
  28. mindcode/bench/adapters/__pycache__/base.cpython-313.pyc +0 -0
  29. mindcode/bench/adapters/__pycache__/mini_bfcl.cpython-313.pyc +0 -0
  30. mindcode/bench/adapters/__pycache__/mini_gaia.cpython-313.pyc +0 -0
  31. mindcode/bench/adapters/__pycache__/mini_terminal.cpython-313.pyc +0 -0
  32. mindcode/bench/adapters/__pycache__/swe_bench.cpython-313.pyc +0 -0
  33. mindcode/bench/adapters/__pycache__/terminal_bench.cpython-313.pyc +0 -0
  34. mindcode/bench/adapters/base.py +45 -0
  35. mindcode/bench/adapters/mini_bfcl.py +18 -0
  36. mindcode/bench/adapters/mini_gaia.py +18 -0
  37. mindcode/bench/adapters/mini_terminal.py +18 -0
  38. mindcode/bench/adapters/swe_bench.py +86 -0
  39. mindcode/bench/adapters/terminal_bench.py +107 -0
  40. mindcode/bench/fake_tools.py +73 -0
  41. mindcode/bench/jsonutil.py +20 -0
  42. mindcode/bench/paths.py +10 -0
  43. mindcode/bench/predictions.py +65 -0
  44. mindcode/bench/report.py +48 -0
  45. mindcode/bench/runner.py +250 -0
  46. mindcode/bench/schema.py +78 -0
  47. mindcode/bench/scorers/__pycache__/base.cpython-313.pyc +0 -0
  48. mindcode/bench/scorers/__pycache__/composite.cpython-313.pyc +0 -0
  49. mindcode/bench/scorers/__pycache__/exact.cpython-313.pyc +0 -0
  50. mindcode/bench/scorers/__pycache__/json_call.cpython-313.pyc +0 -0
  51. mindcode/bench/scorers/__pycache__/swe.cpython-313.pyc +0 -0
  52. mindcode/bench/scorers/__pycache__/terminal.cpython-313.pyc +0 -0
  53. mindcode/bench/scorers/__pycache__/terminal_bench.cpython-313.pyc +0 -0
  54. mindcode/bench/scorers/base.py +22 -0
  55. mindcode/bench/scorers/composite.py +57 -0
  56. mindcode/bench/scorers/exact.py +30 -0
  57. mindcode/bench/scorers/json_call.py +44 -0
  58. mindcode/bench/scorers/swe.py +29 -0
  59. mindcode/bench/scorers/terminal.py +43 -0
  60. mindcode/bench/scorers/terminal_bench.py +25 -0
  61. mindcode/bench/trace.py +34 -0
  62. mindcode/bench/workspace.py +127 -0
  63. mindcode/cli/__init__.py +112 -0
  64. mindcode/cli/__pycache__/__init__.cpython-313.pyc +0 -0
  65. mindcode/cli/__pycache__/_shared.cpython-313.pyc +0 -0
  66. mindcode/cli/_shared.py +43 -0
  67. mindcode/cli/commands/__init__.py +1 -0
  68. mindcode/cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  69. mindcode/cli/commands/__pycache__/bench.cpython-313.pyc +0 -0
  70. mindcode/cli/commands/__pycache__/chat.cpython-313.pyc +0 -0
  71. mindcode/cli/commands/__pycache__/config_cmd.cpython-313.pyc +0 -0
  72. mindcode/cli/commands/__pycache__/remote.cpython-313.pyc +0 -0
  73. mindcode/cli/commands/__pycache__/shell.cpython-313.pyc +0 -0
  74. mindcode/cli/commands/__pycache__/status.cpython-313.pyc +0 -0
  75. mindcode/cli/commands/__pycache__/task.cpython-313.pyc +0 -0
  76. mindcode/cli/commands/__pycache__/terminal.cpython-313.pyc +0 -0
  77. mindcode/cli/commands/bench.py +365 -0
  78. mindcode/cli/commands/chat.py +86 -0
  79. mindcode/cli/commands/config_cmd.py +197 -0
  80. mindcode/cli/commands/remote.py +146 -0
  81. mindcode/cli/commands/shell.py +108 -0
  82. mindcode/cli/commands/status.py +37 -0
  83. mindcode/cli/commands/task.py +417 -0
  84. mindcode/cli/commands/terminal.py +124 -0
  85. mindcode/cli/shell/__init__.py +5 -0
  86. mindcode/cli/shell/__pycache__/__init__.cpython-313.pyc +0 -0
  87. mindcode/cli/shell/__pycache__/completion.cpython-313.pyc +0 -0
  88. mindcode/cli/shell/__pycache__/menu.cpython-313.pyc +0 -0
  89. mindcode/cli/shell/__pycache__/repl.cpython-313.pyc +0 -0
  90. mindcode/cli/shell/__pycache__/slash.cpython-313.pyc +0 -0
  91. mindcode/cli/shell/__pycache__/startup.cpython-313.pyc +0 -0
  92. mindcode/cli/shell/completion.py +72 -0
  93. mindcode/cli/shell/menu.py +88 -0
  94. mindcode/cli/shell/repl.py +597 -0
  95. mindcode/cli/shell/slash.py +480 -0
  96. mindcode/cli/shell/startup.py +55 -0
  97. mindcode/cli/shell/tui.py +897 -0
  98. mindcode/config.py +173 -0
  99. mindcode/mcp.py +205 -0
  100. mindcode/policy.py +173 -0
  101. mindcode/remote.py +216 -0
  102. mindcode/render.py +458 -0
  103. mindcode/runtime.py +541 -0
  104. mindcode/skills.py +154 -0
  105. mindcode/subagents.py +43 -0
  106. mindcode/tasking.py +281 -0
  107. mindcode/terminal/__init__.py +1 -0
  108. mindcode/terminal/__main__.py +17 -0
  109. mindcode/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
  110. mindcode/terminal/__pycache__/__main__.cpython-313.pyc +0 -0
  111. mindcode/terminal_core.py +264 -0
  112. mindcode-0.2.0.dist-info/METADATA +244 -0
  113. mindcode-0.2.0.dist-info/RECORD +116 -0
  114. mindcode-0.2.0.dist-info/WHEEL +5 -0
  115. mindcode-0.2.0.dist-info/entry_points.txt +2 -0
  116. 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())
@@ -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