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.
- mation-0.1.0/.github/workflows/publish.yml +22 -0
- mation-0.1.0/.gitignore +5 -0
- mation-0.1.0/LICENSE +21 -0
- mation-0.1.0/PKG-INFO +7 -0
- mation-0.1.0/README.md +150 -0
- mation-0.1.0/mation/__init__.py +0 -0
- mation-0.1.0/mation/cli.py +372 -0
- mation-0.1.0/mation/cmux.py +52 -0
- mation-0.1.0/mation/daemon.py +134 -0
- mation-0.1.0/mation/daemon_runner.py +6 -0
- mation-0.1.0/mation/prompt.py +39 -0
- mation-0.1.0/mation/state.py +142 -0
- mation-0.1.0/mation/worker.py +217 -0
- mation-0.1.0/pyproject.toml +18 -0
|
@@ -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 }}
|
mation-0.1.0/.gitignore
ADDED
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
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,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"
|