mation 0.1.0__tar.gz

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,22 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: astral-sh/setup-uv@v4
15
+
16
+ - name: Build
17
+ run: uv build
18
+
19
+ - name: Publish
20
+ run: uv publish
21
+ env:
22
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
mation-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mation-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mation
3
+ Version: 0.1.0
4
+ Summary: CC Worker orchestration CLI — adopt, monitor, and drive Claude Code agents via cmux
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: typer>=0.15.0
mation-0.1.0/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # mation
2
+
3
+ CC Worker orchestration CLI — adopt, monitor, and drive Claude Code agents via [cmux](https://cmux.com).
4
+
5
+ ## The Three-Party System
6
+
7
+ `mation` implements a relay system between three parties: **Human**, **Supervisor CC**, and **Worker CC**. At least one of them is always active — the system never stalls.
8
+
9
+ ```
10
+ Worker stops → Daemon relays → Supervisor wakes up, evaluates, instructs Worker
11
+ → Worker works → stops → Daemon relays → ...
12
+
13
+ All plans done → Supervisor notifies Human → Human decides: more plans or done
14
+ ```
15
+
16
+ - **Worker** does the actual coding work. It doesn't know it's being managed.
17
+ - **Supervisor** is the Human's agent. It evaluates Worker output, runs verification (by telling Worker to review/test), and drives plans forward.
18
+ - **Human** sets the mission direction and makes final decisions. Only gets notified when Supervisor needs input.
19
+ - **Daemon** is the relay — when Worker stops, it instantly notifies Supervisor. Pure event bridge, no decisions.
20
+
21
+ The three never all idle at once. Worker stops → Supervisor picks up. Supervisor sends instruction → Worker picks up. Like monks taking turns ringing the bell.
22
+
23
+ ## Requirements
24
+
25
+ - [cmux](https://cmux.com) with socket mode set to **Automation** (Settings → Socket Control Mode)
26
+ - Python 3.12+
27
+ - [uv](https://github.com/astral-sh/uv)
28
+ - CC Stop hook configured (see [Setup](#setup))
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ uv tool install .
34
+ ```
35
+
36
+ ## Setup
37
+
38
+ Configure the CC Stop hook globally so `mation` knows when a Worker finishes responding:
39
+
40
+ ```json
41
+ // ~/.claude/settings.json (or your cac env settings)
42
+ {
43
+ "hooks": {
44
+ "Stop": [
45
+ {
46
+ "hooks": [
47
+ {
48
+ "type": "command",
49
+ "command": "bash ~/.mation/on-stop.sh"
50
+ }
51
+ ]
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ ```
57
+
58
+ Create the hook script:
59
+
60
+ ```bash
61
+ mkdir -p ~/.mation
62
+ cat > ~/.mation/on-stop.sh << 'EOF'
63
+ #!/bin/bash
64
+ INPUT=$(cat)
65
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
66
+ echo "$INPUT" > ~/.mation/stop-${SESSION_ID}
67
+ EOF
68
+ chmod +x ~/.mation/on-stop.sh
69
+ ```
70
+
71
+ ## Quick start
72
+
73
+ ```bash
74
+ # Start daemon (background)
75
+ mation start
76
+
77
+ # Set the mission goal
78
+ mation prompt set "Implement chat read receipts"
79
+
80
+ # Adopt a running CC worker
81
+ # (get session name from worker's exit: claude --resume "xxx")
82
+ mation adopt my-worker
83
+
84
+ # Queue plans
85
+ mation plan add "Create message_reads table" "Backend read API" "Frontend read status"
86
+
87
+ # Daemon watches Worker. When Worker stops, Supervisor gets notified.
88
+ # Supervisor evaluates, then instructs Worker:
89
+ mation send "review your changes"
90
+ mation send "run tests"
91
+ mation send "fix the failing test"
92
+
93
+ # Mark plan complete when satisfied
94
+ mation plan done 1
95
+
96
+ # Check progress
97
+ mation status
98
+
99
+ # Stop daemon
100
+ mation stop
101
+
102
+ # End mission
103
+ mation done
104
+ ```
105
+
106
+ ## CLI Reference
107
+
108
+ ### Lifecycle
109
+ | Command | Description |
110
+ |---------|-------------|
111
+ | `mation start [-f]` | Start daemon (default: background, `-f`: foreground) |
112
+ | `mation stop` | Stop daemon |
113
+ | `mation status` | Show mission status (JSON) |
114
+ | `mation done` | End mission, clear all state |
115
+
116
+ ### Worker
117
+ | Command | Description |
118
+ |---------|-------------|
119
+ | `mation adopt <session>` | Adopt a CC worker by session name |
120
+ | `mation drop` | Release worker |
121
+ | `mation send "msg"` | Send message to worker |
122
+ | `mation kick` | Interrupt stuck worker + resume |
123
+
124
+ ### Plan
125
+ | Command | Description |
126
+ |---------|-------------|
127
+ | `mation plan add "desc" [-w workflow]` | Add plans |
128
+ | `mation plan ls` | List all plans |
129
+ | `mation plan show <id>` | Show plan details |
130
+ | `mation plan rm <id>` | Remove plan |
131
+ | `mation plan move <id> --top/--before/--after` | Reorder plans |
132
+ | `mation plan done <id>` | Mark plan complete |
133
+
134
+ ### Prompt & Config
135
+ | Command | Description |
136
+ |---------|-------------|
137
+ | `mation prompt set "text"` | Set mission prompt |
138
+ | `mation prompt show` | Show current prompt |
139
+ | `mation set workflow "code,test,review"` | Change default workflow |
140
+
141
+ ## How it works
142
+
143
+ 1. **Worker detection**: `mation adopt` finds the CC process via `ps`, reads `CMUX_SURFACE_ID` from its environment, locates session JSONL by `customTitle`
144
+ 2. **Idle detection**: CC Stop hook writes a signal file when Worker finishes responding. Daemon polls for this file.
145
+ 3. **Communication**: JSON-RPC over cmux Unix socket (`surface.send_text`, `surface.send_key`)
146
+ 4. **Relay**: Daemon consumes the signal file and injects a notification message into Supervisor's terminal. Supervisor CC wakes up and decides what to do next.
147
+
148
+ ## License
149
+
150
+ MIT
File without changes
@@ -0,0 +1,372 @@
1
+ """Mission CLI — CC Worker 托管与任务编排。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ from typing import Optional
11
+
12
+ import typer
13
+
14
+ from mation import cmux
15
+ from mation.state import (
16
+ DEFAULT_WORKFLOW,
17
+ STATE_DIR,
18
+ add_plan,
19
+ delete_state,
20
+ find_plan,
21
+ load_state,
22
+ move_plan,
23
+ new_state,
24
+ remove_plan,
25
+ save_state,
26
+ )
27
+ from mation.worker import resolve_worker
28
+
29
+ app = typer.Typer(help="CC Worker orchestration CLI")
30
+ plan_app = typer.Typer(help="Plan 管理")
31
+ prompt_app = typer.Typer(help="Mission prompt 管理")
32
+ app.add_typer(plan_app, name="plan")
33
+ app.add_typer(prompt_app, name="prompt")
34
+
35
+
36
+ def _require_state() -> dict:
37
+ state = load_state()
38
+ if state is None:
39
+ typer.echo("没有活跃的 mation。先运行 mation start", err=True)
40
+ raise typer.Exit(1)
41
+ return state
42
+
43
+
44
+ def _output(data: dict) -> None:
45
+ typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
46
+
47
+
48
+ def _is_daemon_running(state: dict) -> bool:
49
+ pid = state.get("daemon_pid")
50
+ if not pid:
51
+ return False
52
+ try:
53
+ os.kill(pid, 0)
54
+ return True
55
+ except (ProcessLookupError, TypeError):
56
+ return False
57
+
58
+
59
+ # ── 生命周期 ────────────────────────────────────────────────
60
+
61
+
62
+ @app.command()
63
+ def start(
64
+ foreground: bool = typer.Option(False, "-f", "--foreground", help="前台运行"),
65
+ workflow: str = typer.Option(
66
+ ",".join(DEFAULT_WORKFLOW), "--workflow", "-w", help="默认 workflow,逗号分隔",
67
+ ),
68
+ ) -> None:
69
+ """启动 mation daemon。默认后台运行,-f 前台运行。"""
70
+ state = load_state()
71
+
72
+ if state is None:
73
+ surface = cmux.get_current_surface()
74
+ if not surface:
75
+ typer.echo("未检测到 CMUX_SURFACE_ID,请在 cmux 终端中运行", err=True)
76
+ raise typer.Exit(1)
77
+ wf = [s.strip() for s in workflow.split(",") if s.strip()]
78
+ state = new_state(surface, wf)
79
+ save_state(state)
80
+ elif _is_daemon_running(state):
81
+ typer.echo("Daemon 已在运行", err=True)
82
+ raise typer.Exit(1)
83
+
84
+ if foreground:
85
+ state["daemon_pid"] = os.getpid()
86
+ save_state(state)
87
+ _output({"ok": True, "daemon_pid": os.getpid(), "mode": "foreground"})
88
+ from mation.daemon import run_daemon
89
+ run_daemon()
90
+ else:
91
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
92
+ log_file = open(STATE_DIR / "daemon.log", "a")
93
+ proc = subprocess.Popen(
94
+ [sys.executable, "-m", "mation.daemon_runner"],
95
+ stdout=log_file,
96
+ stderr=log_file,
97
+ )
98
+ state["daemon_pid"] = proc.pid
99
+ save_state(state)
100
+ _output({"ok": True, "daemon_pid": proc.pid, "mode": "background"})
101
+
102
+
103
+ @app.command()
104
+ def stop() -> None:
105
+ """停止 daemon。"""
106
+ state = _require_state()
107
+ daemon_pid = state.get("daemon_pid")
108
+ if daemon_pid:
109
+ try:
110
+ os.kill(daemon_pid, signal.SIGTERM)
111
+ _output({"ok": True, "stopped_pid": daemon_pid})
112
+ except (ProcessLookupError, TypeError):
113
+ _output({"ok": True, "message": "Daemon 已不在运行"})
114
+ else:
115
+ _output({"ok": True, "message": "没有 daemon"})
116
+ state["daemon_pid"] = None
117
+ save_state(state)
118
+
119
+
120
+ @app.command()
121
+ def status() -> None:
122
+ """查看当前 mation 状态。"""
123
+ state = _require_state()
124
+
125
+ plans_summary = []
126
+ for p in state["plans"]:
127
+ plans_summary.append({
128
+ "id": p["id"],
129
+ "status": p["status"],
130
+ "step": p.get("step"),
131
+ "workflow": ",".join(p["workflow"]) if isinstance(p["workflow"], list) else p["workflow"],
132
+ "desc": p["desc"],
133
+ })
134
+
135
+ done_count = sum(1 for p in state["plans"] if p["status"] == "done")
136
+ running_count = sum(1 for p in state["plans"] if p["status"] == "running")
137
+ pending_count = sum(1 for p in state["plans"] if p["status"] == "pending")
138
+
139
+ worker_info = None
140
+ session = state.get("worker_session")
141
+ if session:
142
+ info = resolve_worker(session)
143
+ worker_info = {
144
+ "session": session,
145
+ "running": info.running,
146
+ "pid": info.pid,
147
+ "surface_id": info.surface_id,
148
+ }
149
+
150
+ _output({
151
+ "mission_prompt": state.get("mission_prompt"),
152
+ "supervisor_surface": state["supervisor_surface"],
153
+ "daemon": {"pid": state.get("daemon_pid"), "running": _is_daemon_running(state)},
154
+ "worker": worker_info,
155
+ "plans": {"total": len(state["plans"]), "done": done_count, "running": running_count, "pending": pending_count},
156
+ "plan_list": plans_summary,
157
+ })
158
+
159
+
160
+ @app.command()
161
+ def done() -> None:
162
+ """结束 mation,清除所有状态。"""
163
+ state = _require_state()
164
+ daemon_pid = state.get("daemon_pid")
165
+ if daemon_pid:
166
+ try:
167
+ os.kill(daemon_pid, signal.SIGTERM)
168
+ except (ProcessLookupError, TypeError):
169
+ pass
170
+ delete_state()
171
+ _output({"ok": True, "message": "Mission 结束"})
172
+
173
+
174
+ # ── Worker ──────────────────────────────────────────────────
175
+
176
+
177
+ @app.command()
178
+ def adopt(
179
+ session: str = typer.Argument(..., help="Worker 的 session name"),
180
+ ) -> None:
181
+ """托管一个 CC worker。"""
182
+ state = _require_state()
183
+
184
+ info = resolve_worker(session)
185
+ if not info.running:
186
+ typer.echo(f"未找到运行中的 session '{session}'", err=True)
187
+ raise typer.Exit(1)
188
+ if not info.jsonl_path:
189
+ typer.echo(f"session '{session}' 还没有交互记录,请先发一条消息再 adopt", err=True)
190
+ raise typer.Exit(1)
191
+
192
+ state["worker_session"] = session
193
+ state["worker_cache"] = {
194
+ "pid": info.pid,
195
+ "surface_id": info.surface_id,
196
+ "jsonl_path": str(info.jsonl_path),
197
+ }
198
+ save_state(state)
199
+ _output({"ok": True, "session": session, "pid": info.pid, "surface_id": info.surface_id, "jsonl": str(info.jsonl_path)})
200
+
201
+
202
+ @app.command()
203
+ def drop() -> None:
204
+ """取消托管。"""
205
+ state = _require_state()
206
+ session = state.get("worker_session")
207
+ state["worker_session"] = None
208
+ state["worker_cache"] = None
209
+ save_state(state)
210
+ _output({"ok": True, "released": session})
211
+
212
+
213
+ @app.command()
214
+ def send(message: str = typer.Argument(..., help="发给 worker 的消息")) -> None:
215
+ """给 worker 发一条消息。"""
216
+ state = _require_state()
217
+ session = state.get("worker_session")
218
+ if not session:
219
+ typer.echo("没有托管的 worker", err=True)
220
+ raise typer.Exit(1)
221
+ info = resolve_worker(session)
222
+ if not info.running or not info.surface_id:
223
+ typer.echo(f"Worker '{session}' 未运行", err=True)
224
+ raise typer.Exit(1)
225
+ cmux.send_text(info.surface_id, message)
226
+ _output({"ok": True, "sent_to": session, "hint": "不用等,Worker 完成后 daemon 会通知你"})
227
+
228
+
229
+ @app.command()
230
+ def kick() -> None:
231
+ """打断卡住的 worker 并让它继续。"""
232
+ state = _require_state()
233
+ session = state.get("worker_session")
234
+ if not session:
235
+ typer.echo("没有托管的 worker", err=True)
236
+ raise typer.Exit(1)
237
+ info = resolve_worker(session)
238
+ if not info.running or not info.surface_id:
239
+ typer.echo(f"Worker '{session}' 未运行", err=True)
240
+ raise typer.Exit(1)
241
+ cmux.send_escape(info.surface_id)
242
+ import time
243
+ time.sleep(1)
244
+ cmux.send_text(info.surface_id, "继续当前任务")
245
+ _output({"ok": True, "kicked": session, "hint": "不用等,Worker 完成后 daemon 会通知你"})
246
+
247
+
248
+ # ── Plan ────────────────────────────────────────────────────
249
+
250
+
251
+ @plan_app.command("add")
252
+ def plan_add(
253
+ descriptions: list[str] = typer.Argument(..., help="Plan 描述(支持多个)"),
254
+ workflow: str = typer.Option(None, "--workflow", "-w", help="Workflow 覆盖"),
255
+ before: Optional[int] = typer.Option(None, "--before"),
256
+ after: Optional[int] = typer.Option(None, "--after"),
257
+ ) -> None:
258
+ """添加 plan。"""
259
+ state = _require_state()
260
+ wf = [s.strip() for s in workflow.split(",") if s.strip()] if workflow else None
261
+ added = []
262
+ for desc in descriptions:
263
+ plan = add_plan(state, desc, workflow=wf, before=before, after=after)
264
+ added.append({"id": plan["id"], "desc": plan["desc"]})
265
+ if before is not None or after is not None:
266
+ after = plan["id"]
267
+ before = None
268
+ save_state(state)
269
+ _output({"ok": True, "added": added})
270
+
271
+
272
+ @plan_app.command("ls")
273
+ def plan_ls() -> None:
274
+ """列出所有 plan。"""
275
+ state = _require_state()
276
+ plans = [{"id": p["id"], "status": p["status"], "step": p.get("step"),
277
+ "workflow": ",".join(p["workflow"]) if isinstance(p["workflow"], list) else p["workflow"],
278
+ "desc": p["desc"]} for p in state["plans"]]
279
+ _output({"plans": plans})
280
+
281
+
282
+ @plan_app.command("show")
283
+ def plan_show(plan_id: int = typer.Argument(...)) -> None:
284
+ """查看 plan 详情。"""
285
+ state = _require_state()
286
+ plan = find_plan(state, plan_id)
287
+ if not plan:
288
+ typer.echo(f"Plan #{plan_id} 不存在", err=True)
289
+ raise typer.Exit(1)
290
+ _output(plan)
291
+
292
+
293
+ @plan_app.command("rm")
294
+ def plan_rm(plan_id: int = typer.Argument(...)) -> None:
295
+ """删除 plan。"""
296
+ state = _require_state()
297
+ if not remove_plan(state, plan_id):
298
+ typer.echo(f"Plan #{plan_id} 不存在", err=True)
299
+ raise typer.Exit(1)
300
+ save_state(state)
301
+ _output({"ok": True, "removed": plan_id})
302
+
303
+
304
+ @plan_app.command("move")
305
+ def plan_move(
306
+ plan_id: int = typer.Argument(...),
307
+ top: bool = typer.Option(False, "--top"),
308
+ bottom: bool = typer.Option(False, "--bottom"),
309
+ before: Optional[int] = typer.Option(None, "--before"),
310
+ after: Optional[int] = typer.Option(None, "--after"),
311
+ ) -> None:
312
+ """调整 plan 顺序。"""
313
+ state = _require_state()
314
+ if not move_plan(state, plan_id, top=top, bottom=bottom, before=before, after=after):
315
+ typer.echo(f"Plan #{plan_id} 不存在", err=True)
316
+ raise typer.Exit(1)
317
+ save_state(state)
318
+ _output({"ok": True, "moved": plan_id})
319
+
320
+
321
+ @plan_app.command("done")
322
+ def plan_done(plan_id: int = typer.Argument(...)) -> None:
323
+ """手动标记 plan 完成。"""
324
+ state = _require_state()
325
+ plan = find_plan(state, plan_id)
326
+ if not plan:
327
+ typer.echo(f"Plan #{plan_id} 不存在", err=True)
328
+ raise typer.Exit(1)
329
+ plan["status"] = "done"
330
+ plan["step"] = None
331
+ save_state(state)
332
+ _output({"ok": True, "plan": plan_id, "status": "done"})
333
+
334
+
335
+ # ── Prompt ──────────────────────────────────────────────────
336
+
337
+
338
+ @prompt_app.command("show")
339
+ def prompt_show() -> None:
340
+ """查看 mation prompt。"""
341
+ state = _require_state()
342
+ _output({"mission_prompt": state.get("mission_prompt")})
343
+
344
+
345
+ @prompt_app.command("set")
346
+ def prompt_set(text: str = typer.Argument(...)) -> None:
347
+ """设置 mation prompt。"""
348
+ state = _require_state()
349
+ state["mission_prompt"] = text
350
+ save_state(state)
351
+ _output({"ok": True, "mission_prompt": text})
352
+
353
+
354
+ # ── Set ─────────────────────────────────────────────────────
355
+
356
+
357
+ @app.command("set")
358
+ def set_config(key: str = typer.Argument(...), value: str = typer.Argument(...)) -> None:
359
+ """修改 mation 配置。"""
360
+ state = _require_state()
361
+ if key == "workflow":
362
+ wf = [s.strip() for s in value.split(",") if s.strip()]
363
+ state["workflow"] = wf
364
+ save_state(state)
365
+ _output({"ok": True, "workflow": wf})
366
+ else:
367
+ typer.echo(f"未知配置项: {key}", err=True)
368
+ raise typer.Exit(1)
369
+
370
+
371
+ if __name__ == "__main__":
372
+ app()
@@ -0,0 +1,52 @@
1
+ """cmux JSON-RPC socket 通信层。
2
+
3
+ 直连 Unix socket,不依赖 cmux CLI,任何进程都能用(需 automation 模式)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import socket
11
+
12
+
13
+ SOCKET_PATH = os.environ.get(
14
+ "CMUX_SOCKET_PATH",
15
+ os.path.expanduser("~/Library/Application Support/cmux/cmux.sock"),
16
+ )
17
+
18
+
19
+ def rpc(method: str, params: dict | None = None) -> dict:
20
+ payload = {"id": "mation", "method": method, "params": params or {}}
21
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
22
+ s.connect(SOCKET_PATH)
23
+ s.sendall(json.dumps(payload).encode() + b"\n")
24
+ s.settimeout(10)
25
+ data = s.recv(65536).decode()
26
+ resp = json.loads(data)
27
+ if not resp.get("ok"):
28
+ error = resp.get("error", {})
29
+ msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
30
+ raise RuntimeError(f"cmux {method}: {msg}")
31
+ return resp.get("result", {})
32
+
33
+
34
+ def get_current_surface() -> str | None:
35
+ return os.environ.get("CMUX_SURFACE_ID")
36
+
37
+
38
+ def send_text(surface_id: str, text: str) -> None:
39
+ rpc("surface.send_text", {"surface_id": surface_id, "text": text})
40
+ rpc("surface.send_key", {"surface_id": surface_id, "key": "enter"})
41
+
42
+
43
+ def send_escape(surface_id: str) -> None:
44
+ rpc("surface.send_key", {"surface_id": surface_id, "key": "escape"})
45
+
46
+
47
+ def ping() -> bool:
48
+ try:
49
+ rpc("system.ping")
50
+ return True
51
+ except Exception:
52
+ return False
@@ -0,0 +1,134 @@
1
+ """后台守护进程:事件接力 + 进度提醒。
2
+
3
+ 两个职责:
4
+ 1. Worker Stop 信号 → 通知 Supervisor(正常接力)
5
+ 2. 两者都停了 → 提醒 Supervisor 当前进度和剩余 plan(防遗忘)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import signal
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from mation import cmux
20
+ from mation.state import STATE_DIR, load_state
21
+
22
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
23
+ LOG_FILE = STATE_DIR / "daemon.log"
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [daemon] %(message)s",
28
+ handlers=[logging.FileHandler(LOG_FILE)],
29
+ )
30
+ log = logging.getLogger(__name__)
31
+
32
+ POLL_INTERVAL = 2
33
+ IDLE_REMIND_TIMEOUT = 120 # 两者都停了多久后提醒 supervisor
34
+
35
+
36
+ def stop_file(session_id: str) -> Path:
37
+ return STATE_DIR / f"stop-{session_id}"
38
+
39
+
40
+ def _get_worker_session_id(state: dict[str, Any]) -> str | None:
41
+ cache = state.get("worker_cache") or {}
42
+ jsonl = cache.get("jsonl_path", "")
43
+ if jsonl:
44
+ return Path(jsonl).stem
45
+ return state.get("worker_session")
46
+
47
+
48
+ def _plan_summary(state: dict[str, Any]) -> str:
49
+ plans = state.get("plans", [])
50
+ if not plans:
51
+ return "没有 plan。"
52
+ done = sum(1 for p in plans if p["status"] == "done")
53
+ total = len(plans)
54
+ pending = [p for p in plans if p["status"] in ("pending", "running")]
55
+ summary = f"Plan 进度:{done}/{total} 完成。"
56
+ if pending:
57
+ next_plans = ", ".join(f"#{p['id']} {p['desc']}" for p in pending[:3])
58
+ summary += f" 待推进:{next_plans}"
59
+ return summary
60
+
61
+
62
+ def notify_supervisor(state: dict[str, Any], message: str) -> None:
63
+ surface = state.get("supervisor_surface")
64
+ if not surface:
65
+ return
66
+ log.info(f"→ Supervisor: {message[:120]}")
67
+ try:
68
+ cmux.send_text(surface, message)
69
+ except RuntimeError as e:
70
+ log.error(f"Failed to notify supervisor: {e}")
71
+
72
+
73
+ def run_daemon() -> None:
74
+ log.info("Daemon started (pid=%d)", os.getpid())
75
+
76
+ both_idle_since: float | None = None
77
+
78
+ def handle_signal(signum: int, frame: Any) -> None:
79
+ log.info("Daemon stopping")
80
+ sys.exit(0)
81
+
82
+ signal.signal(signal.SIGTERM, handle_signal)
83
+ signal.signal(signal.SIGINT, handle_signal)
84
+
85
+ while True:
86
+ try:
87
+ state = load_state()
88
+ if state is None:
89
+ log.info("No active mation, exiting")
90
+ break
91
+
92
+ session = state.get("worker_session")
93
+ if not session:
94
+ time.sleep(POLL_INTERVAL)
95
+ continue
96
+
97
+ session_id = _get_worker_session_id(state)
98
+ if not session_id:
99
+ time.sleep(POLL_INTERVAL)
100
+ continue
101
+
102
+ sf = stop_file(session_id)
103
+
104
+ if sf.exists():
105
+ # Worker 停了 → 读信号 → 通知 Supervisor → 消费
106
+ try:
107
+ stop_data = json.loads(sf.read_text())
108
+ last_msg = stop_data.get("last_assistant_message", "")
109
+ except (json.JSONDecodeError, OSError):
110
+ last_msg = ""
111
+
112
+ sf.unlink()
113
+ both_idle_since = None
114
+
115
+ summary = _plan_summary(state)
116
+ notify_supervisor(state, f"[Mation] Worker 停了。回复:{last_msg[:300]} --- {summary}")
117
+ log.info("Stop signal consumed, supervisor notified")
118
+
119
+ else:
120
+ # 没有 Stop 信号 → Worker 在忙或者两者都停了
121
+ # 如果 Worker 在忙,不需要提醒
122
+ # 如果两者都停了(一段时间没有 Stop 信号),提醒 Supervisor
123
+ if both_idle_since is None:
124
+ both_idle_since = time.time()
125
+ elif time.time() - both_idle_since > IDLE_REMIND_TIMEOUT:
126
+ summary = _plan_summary(state)
127
+ notify_supervisor(state, f"[Mation] {IDLE_REMIND_TIMEOUT}s 没有活动。{summary}")
128
+ log.info("Both idle, reminded supervisor")
129
+ both_idle_since = time.time()
130
+
131
+ except Exception as e:
132
+ log.error(f"Daemon error: {e}")
133
+
134
+ time.sleep(POLL_INTERVAL)
@@ -0,0 +1,6 @@
1
+ """Daemon 入口点,供 subprocess 调用。"""
2
+
3
+ from mation.daemon import run_daemon
4
+
5
+ if __name__ == "__main__":
6
+ run_daemon()
@@ -0,0 +1,39 @@
1
+ """Prompt 拼接逻辑。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mation.state import load_config
8
+
9
+
10
+ def build_message(
11
+ state: dict[str, Any],
12
+ plan: dict[str, Any],
13
+ step_name: str,
14
+ ) -> str:
15
+ """根据 mission prompt + plan/step 拼接最终发给 worker 的消息。"""
16
+ config = load_config()
17
+ step_prompts = config.get("step_prompts", {})
18
+ mission_prompt = state.get("mission_prompt") or ""
19
+
20
+ step_prompt = step_prompts.get(step_name)
21
+
22
+ # slash command 不拼接 mission prompt
23
+ if step_prompt and step_prompt.startswith("/"):
24
+ return step_prompt
25
+
26
+ parts: list[str] = []
27
+
28
+ if mission_prompt:
29
+ parts.append(mission_prompt)
30
+ parts.append("---")
31
+
32
+ if step_name == "code" or step_prompt is None:
33
+ # code 步骤:发 plan 描述
34
+ parts.append(plan["desc"])
35
+ else:
36
+ parts.append(step_prompt)
37
+
38
+ # 单行拼接,避免 CC TUI 多行输入模式(多行时 enter 变换行不提交)
39
+ return " --- ".join(parts)
@@ -0,0 +1,142 @@
1
+ """Mation 状态持久化。读写 ~/.mation/active.json"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ STATE_DIR = Path.home() / ".mation"
10
+ STATE_FILE = STATE_DIR / "active.json"
11
+ CONFIG_FILE = STATE_DIR / "config.json"
12
+
13
+ DEFAULT_WORKFLOW = ["code", "test", "simplify", "review", "fix"]
14
+ DEFAULT_STEP_PROMPTS: dict[str, str | None] = {
15
+ "code": None,
16
+ "test": "对你刚才的改动跑测试,修复失败的用例",
17
+ "simplify": "/simplify",
18
+ "review": "/review",
19
+ "fix": "修复 review 中发现的问题",
20
+ }
21
+
22
+
23
+ def _ensure_dir() -> None:
24
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ def load_state() -> dict[str, Any] | None:
28
+ if not STATE_FILE.exists():
29
+ return None
30
+ return json.loads(STATE_FILE.read_text())
31
+
32
+
33
+ def save_state(state: dict[str, Any]) -> None:
34
+ _ensure_dir()
35
+ STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
36
+
37
+
38
+ def delete_state() -> None:
39
+ if STATE_FILE.exists():
40
+ STATE_FILE.unlink()
41
+
42
+
43
+ def load_config() -> dict[str, Any]:
44
+ if not CONFIG_FILE.exists():
45
+ return {"step_prompts": dict(DEFAULT_STEP_PROMPTS)}
46
+ return json.loads(CONFIG_FILE.read_text())
47
+
48
+
49
+ def save_config(config: dict[str, Any]) -> None:
50
+ _ensure_dir()
51
+ CONFIG_FILE.write_text(json.dumps(config, ensure_ascii=False, indent=2))
52
+
53
+
54
+ def new_state(supervisor_surface: str, workflow: list[str]) -> dict[str, Any]:
55
+ return {
56
+ "supervisor_surface": supervisor_surface,
57
+ "worker_session": None,
58
+ "worker_cache": None,
59
+ "daemon_pid": None,
60
+ "workflow": workflow,
61
+ "mission_prompt": None,
62
+ "plans": [],
63
+ "next_plan_id": 1,
64
+ }
65
+
66
+
67
+ def add_plan(
68
+ state: dict[str, Any],
69
+ desc: str,
70
+ workflow: list[str] | None = None,
71
+ before: int | None = None,
72
+ after: int | None = None,
73
+ ) -> dict[str, Any]:
74
+ plan = {
75
+ "id": state["next_plan_id"],
76
+ "desc": desc,
77
+ "workflow": workflow or state["workflow"],
78
+ "status": "pending",
79
+ "step": None,
80
+ "step_index": 0,
81
+ }
82
+ state["next_plan_id"] += 1
83
+
84
+ plans = state["plans"]
85
+ if before is not None:
86
+ idx = next((i for i, p in enumerate(plans) if p["id"] == before), None)
87
+ if idx is not None:
88
+ plans.insert(idx, plan)
89
+ else:
90
+ plans.append(plan)
91
+ elif after is not None:
92
+ idx = next((i for i, p in enumerate(plans) if p["id"] == after), None)
93
+ if idx is not None:
94
+ plans.insert(idx + 1, plan)
95
+ else:
96
+ plans.append(plan)
97
+ else:
98
+ plans.append(plan)
99
+
100
+ return plan
101
+
102
+
103
+ def find_plan(state: dict[str, Any], plan_id: int) -> dict[str, Any] | None:
104
+ return next((p for p in state["plans"] if p["id"] == plan_id), None)
105
+
106
+
107
+ def move_plan(
108
+ state: dict[str, Any],
109
+ plan_id: int,
110
+ *,
111
+ top: bool = False,
112
+ bottom: bool = False,
113
+ before: int | None = None,
114
+ after: int | None = None,
115
+ ) -> bool:
116
+ plans = state["plans"]
117
+ idx = next((i for i, p in enumerate(plans) if p["id"] == plan_id), None)
118
+ if idx is None:
119
+ return False
120
+
121
+ plan = plans.pop(idx)
122
+
123
+ if top:
124
+ plans.insert(0, plan)
125
+ elif bottom:
126
+ plans.append(plan)
127
+ elif before is not None:
128
+ target_idx = next((i for i, p in enumerate(plans) if p["id"] == before), len(plans))
129
+ plans.insert(target_idx, plan)
130
+ elif after is not None:
131
+ target_idx = next((i for i, p in enumerate(plans) if p["id"] == after), len(plans) - 1)
132
+ plans.insert(target_idx + 1, plan)
133
+ else:
134
+ plans.append(plan)
135
+
136
+ return True
137
+
138
+
139
+ def remove_plan(state: dict[str, Any], plan_id: int) -> bool:
140
+ before_len = len(state["plans"])
141
+ state["plans"] = [p for p in state["plans"] if p["id"] != plan_id]
142
+ return len(state["plans"]) < before_len
@@ -0,0 +1,217 @@
1
+ """Worker 解析:从 session name 查找运行时信息(PID、workspace、surface、JSONL 路径)。
2
+
3
+ 查找链路:
4
+ session name → ps 找 "claude.*<session>" → PID
5
+ PID → ps eww 读环境变量 → CMUX_WORKSPACE_ID, CMUX_SURFACE_ID
6
+ session name → JSONL 目录匹配文件名
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass
18
+ class WorkerInfo:
19
+ session: str
20
+ pid: int | None = None
21
+ workspace_id: str | None = None # cmux UUID
22
+ surface_id: str | None = None # cmux UUID
23
+ jsonl_path: Path | None = None
24
+ running: bool = False
25
+
26
+
27
+ def _find_claude_pid(session: str) -> int | None:
28
+ """从 ps 找到 claude --resume <session> 的实际二进制 PID(跳过 bash wrapper)。"""
29
+ try:
30
+ result = subprocess.run(
31
+ ["ps", "-eo", "pid,args"],
32
+ capture_output=True, text=True, timeout=5,
33
+ )
34
+ except Exception:
35
+ return None
36
+
37
+ candidates: list[tuple[int, str]] = []
38
+ for line in result.stdout.strip().split("\n"):
39
+ line = line.strip()
40
+ if "claude" not in line:
41
+ continue
42
+ # 匹配 --resume <session> 或 --name <session>
43
+ matched = False
44
+ for flag in ("--resume", "--name"):
45
+ if f"{flag} {session}" in line or f'{flag} "{session}"' in line:
46
+ matched = True
47
+ break
48
+ if not matched:
49
+ continue
50
+ parts = line.split()
51
+ try:
52
+ pid = int(parts[0])
53
+ args = " ".join(parts[1:])
54
+ candidates.append((pid, args))
55
+ except (ValueError, IndexError):
56
+ pass
57
+
58
+ if not candidates:
59
+ return None
60
+
61
+ # 优先选非 bash wrapper 的进程(实际二进制)
62
+ for pid, args in candidates:
63
+ if "bash" not in args:
64
+ return pid
65
+
66
+ # fallback:返回第一个
67
+ return candidates[0][0]
68
+
69
+
70
+ def _read_env_from_pid(pid: int, var: str) -> str | None:
71
+ """从进程环境变量读取指定变量(macOS ps eww)。"""
72
+ try:
73
+ result = subprocess.run(
74
+ ["ps", "eww", "-p", str(pid)],
75
+ capture_output=True, text=True, timeout=5,
76
+ )
77
+ except Exception:
78
+ return None
79
+
80
+ for token in result.stdout.replace("\n", " ").split(" "):
81
+ if token.startswith(f"{var}="):
82
+ return token.split("=", 1)[1]
83
+ return None
84
+
85
+
86
+ def _find_jsonl(session: str) -> Path | None:
87
+ """在 .cac 和 .claude 目录下找 session 对应的 JSONL 文件。
88
+
89
+ session 可以是 UUID(直接匹配文件名)或 display name(从 customTitle 匹配)。
90
+ """
91
+ search_dirs = [
92
+ Path.home() / ".cac" / "envs" / "main" / ".claude" / "projects",
93
+ Path.home() / ".claude" / "projects",
94
+ ]
95
+
96
+ for search_dir in search_dirs:
97
+ if not search_dir.exists():
98
+ continue
99
+
100
+ # 1. 直接匹配 UUID 文件名
101
+ for jsonl_path in search_dir.rglob(f"{session}.jsonl"):
102
+ return jsonl_path
103
+
104
+ # 2. 按 customTitle 匹配(session name 不是 UUID 的情况)
105
+ for jsonl_path in search_dir.rglob("*.jsonl"):
106
+ try:
107
+ with open(jsonl_path) as f:
108
+ first_line = f.readline()
109
+ if not first_line:
110
+ continue
111
+ entry = json.loads(first_line)
112
+ if entry.get("type") == "custom-title" and entry.get("customTitle") == session:
113
+ return jsonl_path
114
+ except (json.JSONDecodeError, OSError):
115
+ continue
116
+
117
+ return None
118
+
119
+
120
+ def resolve_worker(session: str) -> WorkerInfo:
121
+ """解析 worker 的完整运行时信息。"""
122
+ info = WorkerInfo(session=session)
123
+
124
+ # 1. 找 PID
125
+ pid = _find_claude_pid(session)
126
+ if pid is None:
127
+ info.running = False
128
+ # 仍然尝试找 JSONL(进程可能已退出但文件还在)
129
+ info.jsonl_path = _find_jsonl(session)
130
+ return info
131
+
132
+ info.pid = pid
133
+ info.running = True
134
+
135
+ # 2. 从 PID 读 cmux 信息
136
+ info.workspace_id = _read_env_from_pid(pid, "CMUX_WORKSPACE_ID")
137
+ info.surface_id = _read_env_from_pid(pid, "CMUX_SURFACE_ID")
138
+
139
+ # 3. 找 JSONL
140
+ info.jsonl_path = _find_jsonl(session)
141
+
142
+ return info
143
+
144
+
145
+ def get_worker_state_from_jsonl(jsonl_path: Path) -> dict:
146
+ """从 JSONL 读取 worker 最新状态。
147
+
148
+ 返回:
149
+ {"status": "idle"|"busy"|"interrupted"|"unknown",
150
+ "last_type": str, "last_timestamp": str, "last_text": str}
151
+ """
152
+ if not jsonl_path.exists():
153
+ return {"status": "unknown"}
154
+
155
+ # 读最后几行
156
+ try:
157
+ lines = jsonl_path.read_text().strip().split("\n")
158
+ except Exception:
159
+ return {"status": "unknown"}
160
+
161
+ if not lines:
162
+ return {"status": "unknown"}
163
+
164
+ # 从后往前找最近的真实 user/assistant 消息(跳过 local command)
165
+ last_entry = None
166
+ for line in reversed(lines[-50:]):
167
+ try:
168
+ entry = json.loads(line)
169
+ except json.JSONDecodeError:
170
+ continue
171
+
172
+ entry_type = entry.get("type")
173
+
174
+ if entry_type == "assistant":
175
+ last_entry = entry
176
+ break
177
+
178
+ if entry_type == "user":
179
+ # 跳过 local command(/model, /clear 等)
180
+ content = entry.get("message", {}).get("content", "")
181
+ content_str = str(content)
182
+ if "<command-name>" in content_str or "<local-command" in content_str:
183
+ continue
184
+ last_entry = entry
185
+ break
186
+
187
+ if last_entry is None:
188
+ return {"status": "unknown"}
189
+
190
+ entry_type = last_entry["type"]
191
+ timestamp = last_entry.get("timestamp", "")
192
+
193
+ if entry_type == "assistant":
194
+ # 最后一条是 assistant 回复 → idle
195
+ content = last_entry.get("message", {}).get("content", "")
196
+ text = ""
197
+ if isinstance(content, list):
198
+ for c in content:
199
+ if c.get("type") == "text":
200
+ text = c["text"][:200]
201
+ break
202
+ else:
203
+ text = str(content)[:200]
204
+
205
+ return {"status": "idle", "last_type": "assistant", "last_timestamp": timestamp, "last_text": text}
206
+
207
+ elif entry_type == "user":
208
+ content = last_entry.get("message", {}).get("content", "")
209
+ # 检查是否是中断
210
+ if isinstance(content, list):
211
+ for c in content:
212
+ if isinstance(c, dict) and "Request interrupted" in str(c.get("text", "")):
213
+ return {"status": "interrupted", "last_type": "user", "last_timestamp": timestamp, "last_text": "interrupted"}
214
+
215
+ return {"status": "busy", "last_type": "user", "last_timestamp": timestamp, "last_text": ""}
216
+
217
+ return {"status": "unknown"}
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "mation"
3
+ version = "0.1.0"
4
+ description = "CC Worker orchestration CLI — adopt, monitor, and drive Claude Code agents via cmux"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "typer>=0.15.0",
8
+ ]
9
+
10
+ [project.scripts]
11
+ mation = "mation.cli:app"
12
+
13
+ [tool.hatch.build.targets.wheel]
14
+ packages = ["mation"]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"