feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,2 @@
# agentos - AgentOS 系统管理 CLI
# 统一入口:换机初始化 / 双机同步 / 技能管理 / 备份还原 / 健康检查
@@ -0,0 +1,3 @@
"""agentos CLI 入口 — python -m agentos"""
from .main import main
main()
@@ -0,0 +1,193 @@
"""
agentos backup / restore — 备份与还原 agent-local/
备份内容:
- materials/ (采集的原始素材)
- memory/raw/ (L3 记忆原文)
+ 备份清单 manifest.json
不备份:
- vector_db/ (可重建)
- runtime/cache/ (临时缓存)
"""
import sys
import json
import tarfile
import shutil
from pathlib import Path
from datetime import datetime
from .utils import get_sync_root, get_local_root, info, ok, warn, err, banner
def build_manifest(local_root: Path, backup_dirs: list) -> dict:
"""生成备份清单"""
manifest = {
"created_at": datetime.now().isoformat(),
"hostname": __import__("platform").node(),
"agentos_version": "1.0.0",
"items": [],
}
total_size = 0
entry_count = 0
for rel in backup_dirs:
path = local_root / rel
if not path.exists():
continue
if path.is_dir():
for f in path.rglob("*"):
if f.is_file() and f.name != ".DS_Store":
stat = f.stat()
total_size += stat.st_size
entry_count += 1
manifest["items"].append({
"path": str(f.relative_to(local_root)),
"size": stat.st_size,
"mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
else:
stat = path.stat()
total_size += stat.st_size
entry_count += 1
manifest["items"].append({
"path": str(path.relative_to(local_root)),
"size": stat.st_size,
"mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
manifest["total_size"] = total_size
manifest["entry_count"] = entry_count
return manifest
def cmd_backup(output_path: str = None, full: bool = False):
"""执行备份"""
banner()
local_root = get_local_root()
hostname = __import__("platform").node()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if not local_root.exists():
err(f"agent-local 目录不存在: {local_root}")
sys.exit(1)
# 确定备份目录
if full:
sync_root = get_sync_root()
backup_dirs = ["."] # 打包整个 agent-local/
archive_name = f"agentos_full_{hostname}_{timestamp}.tar.gz"
else:
backup_dirs = ["materials", "memory/raw"]
archive_name = f"agentos_local_{hostname}_{timestamp}.tar.gz"
# 输出路径
if output_path:
out_dir = Path(output_path)
else:
out_dir = Path.cwd()
out_dir.mkdir(parents=True, exist_ok=True)
archive_path = out_dir / archive_name
# 构建 manifest
info("扫描备份内容...")
manifest = build_manifest(local_root, backup_dirs)
if manifest["entry_count"] == 0:
warn("没有需要备份的文件")
return
print(f" 📊 待备份: {manifest['entry_count']} 个文件, {manifest['total_size'] / 1024 / 1024:.1f} MB")
print(f" 📁 来源: {local_root}")
# 创建 tar.gz
info("打包中...")
with tarfile.open(str(archive_path), "w:gz") as tar:
# 添加 manifest
manifest_bytes = json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8")
import io
manifest_file = io.BytesIO(manifest_bytes)
tarinfo = tarfile.TarInfo(name="manifest.json")
tarinfo.size = len(manifest_bytes)
tar.addfile(tarinfo, manifest_file)
# 添加备份目录
for rel in backup_dirs:
path = local_root / rel
if path.exists():
tar.add(str(path), arcname=rel)
file_size_mb = archive_path.stat().st_size / 1024 / 1024
ok(f"备份完成: {archive_path} ({file_size_mb:.1f} MB)")
info(f"还原命令: agentos restore {archive_path}")
print()
def run_restore(args):
"""执行还原"""
banner()
backup_path = Path(args.backup_path)
if not backup_path.exists():
err(f"备份文件不存在: {backup_path}")
sys.exit(1)
local_root = get_local_root()
force = args.force
info(f"从备份还原: {backup_path}")
info(f"目标目录: {local_root}")
# 读取 manifest
manifest = None
with tarfile.open(str(backup_path), "r:gz") as tar:
try:
mf = tar.extractfile("manifest.json")
if mf:
manifest = json.loads(mf.read().decode("utf-8"))
except KeyError:
pass
if manifest:
print(f" 📊 备份信息: {manifest['entry_count']} 个文件, {manifest['total_size'] / 1024 / 1024:.1f} MB")
print(f" 🖥️ 来源机器: {manifest.get('hostname', '?')}")
print(f" 🕐 备份时间: {manifest.get('created_at', '?')}")
else:
info(" 备份文件无 manifest.json(旧版本备份)")
# 冲突检查
conflicts = []
if manifest and not force:
for item in manifest["items"]:
dest = local_root / item["path"]
if dest.exists():
conflicts.append(item["path"])
if conflicts:
print(f"\n⚠️ 发现 {len(conflicts)} 个已存在的文件: (使用 --force 覆盖)")
for c in conflicts[:10]:
print(f" {c}")
if len(conflicts) > 10:
print(f" ... 还有 {len(conflicts) - 10} 个")
print()
# 确认
if not force:
reply = input("是否继续还原?(y/N): ").strip().lower()
if reply != "y":
info("已取消")
return
# 执行还原
info("还原中...")
with tarfile.open(str(backup_path), "r:gz") as tar:
tar.extractall(path=str(local_root), filter="data")
ok("还原完成")
info("后续操作:")
info(" 1. 运行 agentos check 验证状态")
info(" 2. 如果向量库未一起还原,运行 agentos init 重建")
print()
def run(args):
cmd_backup(output_path=args.output, full=args.full)
@@ -0,0 +1,228 @@
"""
agentos check — 全系统健康检查
"""
import sys
import subprocess
from pathlib import Path
from .utils import (
get_sync_root, get_local_root, get_python, get_npx,
info, ok, warn, err, run, banner
)
def check_item(name: str, status: str, detail: str = ""):
"""输出检查项"""
icon = {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}
label = {"ok": "通过", "warn": "警告", "fail": "失败"}
print(f" {icon.get(status, '❓')} [{label.get(status, '未知')}] {name}")
if detail:
print(f" {detail}")
def cmd_check(quick: bool = False):
"""执行全系统健康检查"""
banner()
sync_root = get_sync_root()
local_root = get_local_root()
print("=" * 60)
print(" 全系统健康检查")
print("=" * 60)
print()
# 1. 目录结构
print("📁 目录结构:")
check_item("agent-sync 根目录", "ok" if sync_root.exists() else "fail", str(sync_root))
check_item("agent-local 根目录", "ok" if local_root.exists() else "fail", str(local_root))
sync_skills = sync_root / "02_skills"
sync_skills_count = len([d for d in sync_skills.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]) if sync_skills.exists() else 0
check_item(f"技能目录 ({sync_skills_count} 个)", "ok" if sync_skills_count > 0 else "warn")
local_materials = local_root / "materials" if local_root.exists() else None
local_raw = local_root / "memory/raw" if local_root.exists() else None
materials_exist = local_materials and local_materials.exists()
raw_exist = local_raw and local_raw.exists()
check_item("materials/", "ok" if materials_exist else "warn", "采集素材目录")
check_item("memory/raw/", "ok" if raw_exist else "warn", "L3 记忆原文目录")
# 2. 软链接
print("\n🔗 软链接:")
symlinks_to_check = [
("04_memory/long_term/raw", local_root / "memory/raw"),
("04_memory/vector_db", local_root / "memory/vector_db"),
("06_runtime/cache", local_root / "runtime/cache"),
]
for link_rel, target in symlinks_to_check:
link_path = sync_root / link_rel
if link_path.is_symlink():
actual = link_path.resolve()
expected = target.resolve()
if actual == expected:
check_item(f"{link_rel} → OK", "ok")
else:
check_item(f"{link_rel} → 指向错误", "warn", f"实际: {actual}, 期望: {expected}")
elif link_path.exists():
check_item(f"{link_rel} → 真实目录", "ok")
else:
check_item(f"{link_rel} → 不存在", "fail")
# 3. Python 环境
print("\n🐍 Python 环境:")
py = get_python()
if Path(py).exists():
result = run([py, "--version"])
check_item("Python 版本", "ok" if result.returncode == 0 else "fail", result.stdout.strip() if result.returncode == 0 else "")
else:
check_item("Python 可执行文件", "fail", py)
# 关键依赖检查
deps = ["chromadb", "trafilatura", "feedparser", "sqlite_utils"]
for dep in deps:
result = run([py, "-c", f"import {dep}"])
check_item(f"依赖: {dep}", "ok" if result.returncode == 0 else "fail")
if quick:
print("\n" + "=" * 60)
ok("快速检查完成")
return
# 4. oMLX
print("\n🤖 本地 LLM 引擎:")
try:
import json, urllib.request
data = json.dumps({"model": "Qwen3-Embedding-0.6B-4bit-DWQ", "input": ["test"]}).encode()
req = urllib.request.Request(
"http://localhost:8000/v1/embeddings",
data=data,
headers={"Authorization": "Bearer omlx", "Content-Type": "application/json"},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=5)
check_item("oMLX Embedding API", "ok" if resp.status == 200 else "fail", f"状态码 {resp.status}")
except Exception as e:
check_item("oMLX Embedding API", "fail", str(e))
# 音频转文字能力检查
print("\n🎙️ 音频转文字:")
ffmpeg = run(["which", "ffmpeg"])
if ffmpeg.returncode == 0:
check_item("ffmpeg(系统工具)", "ok", "已安装")
else:
check_item("ffmpeg(系统工具)", "warn", "未安装,音频转文字不可用。运行: brew install ffmpeg")
whisper_check = run([py, "-m", "pip", "show", "openai-whisper"])
if whisper_check.returncode == 0:
import re
ver_match = re.search(r"Version:\s*(\S+)", whisper_check.stdout)
ver = ver_match.group(1) if ver_match else "?"
check_item("openai-whisper(Python包)", "ok", f"v{ver}")
# 检查模型是否已下载
import os as _os
whisper_cache = _os.path.expanduser("~/.cache/whisper")
if _os.path.exists(whisper_cache):
models = [f for f in _os.listdir(whisper_cache) if f.endswith(('.pt', '.bin'))]
if models:
model_sizes = [_os.path.getsize(_os.path.join(whisper_cache, m)) / 1024 / 1024 for m in models]
detail = ", ".join([f"{m} ({s:.0f}MB)" for m, s in zip(models, model_sizes)])
check_item("Whisper 模型权重", "ok", f"已缓存: {detail}")
else:
check_item("Whisper 模型权重", "warn", "首次使用时会自动下载(~1.5GB)")
else:
check_item("Whisper 模型权重", "warn", "未下载,首次运行自动下载(~1.5GB)")
else:
check_item("openai-whisper(Python包)", "fail", "未安装,运行: pip install openai-whisper")
# 5. facts.db
print("\n🗄️ 记忆层:")
facts_db = sync_root / "04_memory" / "long_term" / "facts.db"
if facts_db.exists():
size_kb = facts_db.stat().st_size / 1024
result = run([py, "-c", f"""
import sqlite3
conn = sqlite3.connect('{facts_db}')
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM facts")
count = c.fetchone()[0]
c.execute("SELECT COUNT(DISTINCT subject) FROM facts")
subjects = c.fetchone()[0]
conn.close()
print(f"条目: {{count}}, 主题: {{subjects}}")
"""])
check_item(f"facts.db ({size_kb:.1f} KB)", "ok", result.stdout.strip() if result.returncode == 0 else "读取异常")
else:
check_item("facts.db", "warn", "不存在(首次初始化正常)")
# 6. 向量库
vector_db = local_root / "vector_db" / "global" / "chroma"
if vector_db.exists():
chroma_files = list(vector_db.glob("*.sqlite3"))
check_item("global 向量库", "ok" if chroma_files else "warn", f"目录存在但无 .sqlite3 文件" if not chroma_files else "")
else:
check_item("global 向量库", "warn", "未建,运行 agentos init 或 vector_db_rebuild.py 重建")
# 7. WorkBuddy 技能注册
print("\n📦 技能注册状态:")
wb_skills = Path.home() / ".workbuddy" / "skills"
sync_skills_dir = sync_root / "02_skills"
if wb_skills.exists() and sync_skills_dir.exists():
wb_names = {d.name for d in wb_skills.iterdir() if d.is_dir()}
src_names = {d.name for d in sync_skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()}
missing = src_names - wb_names
extra = wb_names - src_names
if missing:
check_item(f"未注册到 WorkBuddy ({len(missing)} 个)", "warn", ", ".join(sorted(missing)))
if extra:
check_item(f"WB 中多余的 ({len(extra)} 个)", "warn", ", ".join(sorted(extra)))
if not missing and not extra:
check_item(f"技能一致性 ({len(src_names)} 个)", "ok", "源目录与 WB 注册一致")
# 8. 磁盘
print("\n💾 磁盘空间:")
import shutil
usage = shutil.disk_usage(str(Path.home()))
free_gb = usage.free / (1024 ** 3)
total_gb = usage.total / (1024 ** 3)
pct = usage.free / usage.total * 100
status = "ok" if pct > 10 else "warn"
check_item(f"磁盘剩余", status, f"{free_gb:.0f} GB / {total_gb:.0f} GB ({pct:.0f}%)")
print()
print("=" * 60)
ok("健康检查完成")
print()
def install_deps(dry_run: bool = False):
"""安装 Python 依赖(被 init 调用)"""
py = get_python()
req_file = get_sync_root() / "requirements.txt"
if not req_file.exists():
warn(f"requirements.txt 不存在: {req_file}")
return
if dry_run:
info(f" [DRY-RUN] pip install -r {req_file}")
return
info("安装 Python 依赖...")
result = run([py, "-m", "pip", "install", "-r", str(req_file), "--quiet"])
if result.returncode == 0:
ok("Python 依赖安装完成")
else:
warn(f"部分依赖安装失败:\n{result.stderr[:200]}")
# 检查并提示安装 ffmpeg(音频转文字需要)
ffmpeg = run(["which", "ffmpeg"])
if ffmpeg.returncode != 0:
print()
warn("音频转文字功能需要 ffmpeg 系统工具")
info(" 运行以下命令安装:")
info(" brew install ffmpeg")
print()
def cmd_run(args):
cmd_check(quick=args.quick)
@@ -0,0 +1,424 @@
"""
agentos config — 配置管理模块 (v1.0.0)
管理 01_core/ ←→ ~/.workbuddy/ 的配置同步。
三类配置:
A: 自动覆盖(内核身份文件)
B: 对比选择(服务配置,diff 后用户选择)
C: 仅报告(本机个性化,不同步)
子命令:
agentos config status 查看配置状态
agentos config diff 查看差异详情
agentos config apply 应用配置更新
agentos config rollback 回滚到备份
最后更新: 2026-05-02
"""
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from .const import __agent_sync_root__, __agent_local_root__, __version__
SYNC_ROOT = Path(__agent_sync_root__)
MANIFEST_PATH = SYNC_ROOT / "01_core" / "CONFIG_MANIFEST.yaml"
# ══════════════════════════════════════════════════════════════
# 配置清单解析
# ══════════════════════════════════════════════════════════════
def load_manifest() -> list:
"""解析 CONFIG_MANIFEST.yaml,返回配置项列表"""
import yaml
if not MANIFEST_PATH.exists():
print(f"❌ 未找到配置清单: {MANIFEST_PATH}")
return []
with open(MANIFEST_PATH) as f:
data = yaml.safe_load(f)
return data.get("configs", [])
def resolve_path(path_str: str) -> Optional[Path]:
"""将 ~/ 开头的路径解析为绝对路径"""
if not path_str:
return None
path_str = path_str.replace("~", str(Path.home()))
p = Path(path_str)
return p if p.exists() else p # 即使不存在也返回路径对象
# ══════════════════════════════════════════════════════════════
# 状态检查
# ══════════════════════════════════════════════════════════════
def check_status() -> list:
"""检查所有配置项状态,返回状态列表"""
items = load_manifest()
if not items:
return []
results = []
for item in items:
cat = item["category"]
name = item["file"]
source = resolve_path(item.get("source"))
target = resolve_path(item.get("target"))
status = {
"name": name,
"category": cat,
"source": str(source) if source else "—",
"target": str(target) if target else "—",
"exists_source": source and source.exists(),
"exists_target": target and target.exists(),
"identical": False,
"diff_lines": 0,
}
# 比较内容
if status["exists_source"] and status["exists_target"]:
try:
s_content = source.read_bytes()
t_content = target.read_bytes()
status["identical"] = s_content == t_content
if not status["identical"]:
# 粗略统计差异行数
s_lines = s_content.decode().splitlines()
t_lines = t_content.decode().splitlines()
status["diff_lines"] = abs(len(s_lines) - len(t_lines))
except Exception:
status["identical"] = False
results.append(status)
return results
# ══════════════════════════════════════════════════════════════
# 差异显示
# ══════════════════════════════════════════════════════════════
def show_diff(item: dict):
"""显示指定配置项的 diff"""
source = resolve_path(item.get("source"))
target = resolve_path(item.get("target"))
if not source or not source.exists():
print(f" ⚠️ 源文件不存在: {source}")
return
if not target or not target.exists():
print(f" ⚠️ 目标文件不存在: {target}")
print(f" → 将创建: {target}")
return
try:
result = subprocess.run(
["diff", "-u", str(source), str(target)],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
print(f" ✅ 文件一致,无差异")
else:
diff_out = result.stdout
lines = diff_out.splitlines()
# 只显示前 30 行
display = lines[:30]
if len(lines) > 30:
display.append(f" ... (还有 {len(lines)-30} 行差异)")
for line in display:
marker = " "
if line.startswith("+"):
marker = "+"
elif line.startswith("-"):
marker = "-"
elif line.startswith("@@"):
marker = "@"
print(f" {marker} {line}")
except Exception as e:
print(f" ❌ diff 失败: {e}")
# ══════════════════════════════════════════════════════════════
# 备份
# ══════════════════════════════════════════════════════════════
def backup_targets(items: list) -> Optional[Path]:
"""备份所有目标文件到时间戳目录"""
backup_dir = Path.home() / ".workbuddy" / f"config_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
count = 0
for item in items:
if item["category"] == "C":
continue
target = resolve_path(item.get("target"))
if target and target.exists():
backup_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(target), str(backup_dir / target.name))
count += 1
if count > 0:
print(f" 💾 已备份 {count} 个文件到: {backup_dir}")
return backup_dir
return None
# ══════════════════════════════════════════════════════════════
# 应用配置
# ══════════════════════════════════════════════════════════════
def apply_config(item: dict) -> bool:
"""应用单个配置项"""
cat = item["category"]
name = item["file"]
source = resolve_path(item.get("source"))
target = resolve_path(item.get("target"))
if not target:
print(f" ⚠️ {name}: 目标路径为空,跳过")
return False
if cat == "A":
# A类: 自动覆盖
if not source or not source.exists():
print(f" ⚠️ {name}: 源文件不存在 ({source}),跳过")
return False
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(source), str(target))
print(f" ✅ {name}: 已部署(A类自动覆盖)")
return True
elif cat == "B":
# B类: 对比后选择
if not source or not source.exists():
print(f" ⚠️ {name}: 源文件不存在 ({source}),跳过")
return False
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
# 检查是否一致
try:
s_content = source.read_bytes()
t_content = target.read_bytes()
if s_content == t_content:
print(f" ✅ {name}: 一致,无需更新")
return True
except Exception:
pass
# 有差异,尝试交互(非交互环境自动跳过)
print(f"\n 📋 {name}: 源与目标存在差异")
print(f" 源: {source}")
print(f" 目标: {target}")
show_diff(item)
print()
try:
choice = input(f" 选择 [K]保留本地 [N]用新版 [D]查看差异 [S]跳过: ").strip().upper()
except (EOFError, OSError):
print(f" ⏭️ {name}: 非交互环境,跳过(运行 agentos config diff {name} 手动处理)")
return False
while True:
if choice == "K":
print(f" ⏭️ {name}: 保留本地版本")
return False
elif choice == "N":
shutil.copy2(str(source), str(target))
print(f" ✅ {name}: 已更新为新版")
return True
elif choice == "D":
show_diff(item)
break
elif choice == "S":
print(f" ⏭️ {name}: 跳过")
return False
else:
break
else:
# 目标不存在,直接部署
shutil.copy2(str(source), str(target))
print(f" ✅ {name}: 已创建(首次部署)")
return True
elif cat == "C":
print(f" ⏭️ {name}: C类本机配置,不同步")
return False
return False
# ══════════════════════════════════════════════════════════════
# CLI 命令
# ══════════════════════════════════════════════════════════════
def cmd_status(args):
"""agentos config status — 查看配置状态"""
results = check_status()
if not results:
print("❌ 无法读取配置清单")
return
print(f"\n{'='*60}")
print(f" 📋 AgentOS 配置状态")
print(f"{'='*60}\n")
for r in results:
cat = r["category"]
name = r["name"]
tag = {"A": "🔵 A-自动", "B": "🟡 B-选择", "C": "⚪ C-本地"}.get(cat, "?")
if cat == "C":
exists = r["exists_target"]
print(f" {tag} {name:20s} {'✅ 存在' if exists else '⏹️ 不存在'}")
elif r["identical"]:
print(f" {tag} {name:20s} ✅ 一致")
elif r["exists_source"] and r["exists_target"]:
print(f" {tag} {name:20s} ⚠️ 有差异(~{r['diff_lines']}行不同)")
elif r["exists_source"] and not r["exists_target"]:
print(f" {tag} {name:20s} 📋 待部署(目标不存在)")
elif not r["exists_source"]:
print(f" {tag} {name:20s} ❌ 源文件不存在")
else:
print(f" {tag} {name:20s} ❓ 未知")
print(f"\n{'─'*60}")
a_count = sum(1 for r in results if r["category"] == "A" and not r["identical"])
b_count = sum(1 for r in results if r["category"] == "B" and not r["identical"])
if a_count + b_count > 0:
print(f" ⚠️ {a_count} 个 A类 + {b_count} 个 B类 待处理")
print(f" 运行: agentos config apply")
else:
print(f" ✅ 所有配置一致")
print()
def cmd_diff(args):
"""agentos config diff — 查看所有差异"""
results = check_status()
if not results:
return
items = load_manifest()
for i, r in enumerate(results):
if r["identical"] or r["category"] == "C":
continue
if r["exists_source"] and r["exists_target"]:
print(f"\n{'='*60}")
print(f" 📄 {r['name']}")
print(f"{'='*60}")
show_diff(items[i])
print()
def cmd_apply(args):
"""agentos config apply — 应用配置更新"""
items = load_manifest()
if not items:
return
print(f"\n{'='*60}")
print(f" 🔄 AgentOS 配置更新")
print(f"{'='*60}\n")
# 先备份
print(f"▶ 备份现有配置...")
backup_dir = backup_targets(items)
if backup_dir:
print(f" 回滚命令: agentos config rollback {backup_dir.name}")
print()
# 逐项应用
changed = 0
for item in items:
if item["category"] == "C":
continue
ok = apply_config(item)
if ok:
changed += 1
print(f"\n{'─'*60}")
if changed > 0:
print(f" ✅ 已更新 {changed} 个配置项")
print(f" 提示: 如果 WorkBuddy 正在运行,可能需要重启生效")
else:
print(f" ⏭️ 无变更")
print()
def cmd_rollback(args):
"""agentos config rollback — 回滚配置"""
backup_name = getattr(args, "backup_name", None)
backup_base = Path.home() / ".workbuddy"
if backup_name:
backup_dir = backup_base / backup_name
else:
# 找最近的备份
backups = sorted(backup_base.glob("config_backup_*"))
if not backups:
print("❌ 未找到备份")
return
backup_dir = backups[-1]
print(f" 使用最近备份: {backup_dir.name}")
if not backup_dir.exists():
print(f"❌ 备份目录不存在: {backup_dir}")
return
files = list(backup_dir.iterdir())
if not files:
print(f"❌ 备份目录为空: {backup_dir}")
return
print(f"\n{'='*60}")
print(f" ↩️ 回滚配置")
print(f"{'='*60}\n")
print(f" 从: {backup_dir}")
print()
for f in files:
target = Path.home() / ".workbuddy" / f.name
shutil.copy2(str(f), str(target))
print(f" ✅ {f.name}: 已恢复")
print(f"\n 共恢复 {len(files)} 个文件")
print()
# ══════════════════════════════════════════════════════════════
# 子命令注册
# ══════════════════════════════════════════════════════════════
def setup_parser(subparsers):
"""向主 CLI 注册 config 子命令"""
p = subparsers.add_parser("config", help="配置管理:状态查看 / 差异对比 / 部署 / 回滚")
config_sub = p.add_subparsers(dest="config_cmd", help="配置子命令")
config_sub.add_parser("status", help="查看配置状态(哪些文件有差异)")
config_sub.add_parser("diff", help="查看配置差异详情")
config_sub.add_parser("apply", help="应用配置更新(A类自动 / B类选择)")
p_rollback = config_sub.add_parser("rollback", help="回滚到上次备份")
p_rollback.add_argument("backup_name", nargs="?", default=None,
help="备份目录名(可选,默认使用最近备份)")
def run(args):
cmd = args.config_cmd
if not cmd:
print("agentos config 子命令: status | diff | apply | rollback")
return
if cmd == "status":
cmd_status(args)
elif cmd == "diff":
cmd_diff(args)
elif cmd == "apply":
cmd_apply(args)
elif cmd == "rollback":
cmd_rollback(args)
@@ -0,0 +1,30 @@
"""
agentos - AgentOS 系统管理 CLI
=================================
统一入口,管理 AgentOS 的全部系统级操作。
用法:
agentos 显示帮助
agentos init 换机一键初始化
agentos sync 双机增量同步
agentos skill list 列出所有技能
agentos skill install 安装技能到 WorkBuddy
agentos skill build 打包技能为 .skill 包
agentos skill search 搜索技能
agentos tool list 列出所有工具
agentos check 全系统健康检查
agentos upgrade [--module <name>] 统一模块升级(拉取代码 + 安装依赖 + 环境检查)
agentos config status 查看配置状态
agentos config diff 查看配置差异
agentos config apply 部署配置(A类自动 / B类选择)
agentos config rollback 回滚至备份
agentos backup 备份 agent-local/(materials + memory/raw)
agentos restore <路径> 从备份还原 agent-local/
"""
from pathlib import Path
__version__ = "2.0.0"
__agent_sync_root__ = str(Path.home() / "workbuddy-agent-os" / "agent-sync")
__agent_local_root__ = str(Path.home() / "workbuddy-agent-os" / "agent-local")
@@ -0,0 +1,385 @@
"""
agentos init — 换机一键初始化
流程:
1. 创建 agent-local/ 目录结构
2. 建立软链接
3. 安装 Python/Node 依赖
4. 注册技能到 WorkBuddy
5. 部署 MCP 配置
6. 创建自动化任务
7. 重建向量库
8. 环境验证
"""
import os
import sys
import shutil
from pathlib import Path
from .utils import (
get_sync_root, get_local_root, get_python, get_npx,
info, ok, warn, err, run, banner
)
from . import skill_mgr
# 本机专属目录结构
LOCAL_DIRS = [
"memory/raw",
"runtime/cache",
"materials/web",
"materials/video",
"materials/audio",
"materials/screenshots",
"materials/refined_for_inbox/clipping",
"materials/refined_for_inbox/feed",
"materials/refined_for_inbox/personal",
"materials/refined_for_inbox/memory",
]
# 软链接映射 {相对路径: 目标路径}
SYMLINKS = {
"04_memory/long_term/raw": "memory/raw",
"04_memory/vector_db": "memory/vector_db",
"06_runtime/cache": "runtime/cache",
}
# 自动化任务定义 (name, prompt, rrule, schedule_type)
AUTOMATIONS = [
{
"name": "AgentOS 每日记忆提炼",
"schedule_type": "recurring",
"rrule": "FREQ=DAILY;BYHOUR=2;BYMINUTE=0",
"prompt": f"""执行 AgentOS 每日记忆提炼任务。
先读取自动化记忆文件(如存在)了解历史。
执行命令:
{get_python()} {get_sync_root() / '02_skills/memory_manager/daily_digest.py'} --root {get_sync_root()}
汇报结果:新增了几条事实、去重了几条、冲突了几条。
写执行摘要到自动化记忆文件。""",
},
{
"name": "AgentOS 收件箱汇聚",
"schedule_type": "recurring",
"rrule": "FREQ=DAILY;BYHOUR=2;BYMINUTE=30",
"prompt": f"""执行 AgentOS 收件箱汇聚任务:扫描知识库各分类目录中的文件,提取主要内容生成标准化 MD 文件放入 00_inbox/。
执行命令:
{get_python()} {get_sync_root() / '02_skills/collect_to_inbox/collect_to_inbox.py'} --root {get_sync_root()}
汇报结果:扫描了几个目录、处理了几个文件、新增了几个收件箱文件。""",
},
{
"name": "AgentOS 收件箱提纯",
"schedule_type": "recurring",
"rrule": "FREQ=DAILY;BYHOUR=3;BYMINUTE=0",
"prompt": f"""执行 AgentOS 收件箱提纯任务:每日将 00_inbox/ 中的内容分类归档到知识库,更新首页统计和变更日志。
执行命令:
{get_python()} {get_sync_root() / '02_skills/inbox_refine/inbox_refine.py'} --root {get_sync_root()}
汇报结果:处理了几个收件箱文件、归档到了哪个分类目录。""",
},
]
def step_local_dirs(dry_run: bool):
"""步骤1: 创建 agent-local/ 目录结构"""
local_root = get_local_root()
info(f"创建本地目录结构: {local_root}")
for subdir in LOCAL_DIRS:
target = local_root / subdir
if not target.exists():
if dry_run:
info(f" [DRY-RUN] 创建: {target}")
else:
target.mkdir(parents=True, exist_ok=True)
ok(f" 创建: {subdir}")
else:
ok(f" 已存在: {subdir}")
def step_symlinks(dry_run: bool):
"""步骤2: 重建软链接"""
sync_root = get_sync_root()
local_root = get_local_root()
info("重建软链接...")
for link_rel, target_rel in SYMLINKS.items():
link_path = sync_root / link_rel
target_path = local_root / target_rel
if dry_run:
info(f" [DRY-RUN] 软链: {link_rel} → {target_path}")
continue
if link_path.is_symlink():
current_target = os.readlink(str(link_path))
if current_target == str(target_path):
ok(f" 软链正确: {link_rel}")
continue
info(f" 更新软链: {link_rel} → {target_path}")
link_path.unlink()
elif link_path.exists():
warn(f" {link_rel} 是真实目录,保留不动")
continue
link_path.parent.mkdir(parents=True, exist_ok=True)
link_path.symlink_to(target_path)
ok(f" 创建软链: {link_rel} → {target_path}")
def step_install_skills(dry_run: bool):
"""步骤3: 注册技能到 WorkBuddy"""
if dry_run:
info(" [DRY-RUN] 安装所有技能到 WorkBuddy")
return
info("安装技能到 WorkBuddy...")
skill_mgr.install_all(get_sync_root() / "02_skills")
def step_deploy_mcp(dry_run: bool):
"""步骤4: 部署 MCP 配置"""
sync_root = get_sync_root()
mcp_src = sync_root / "01_core" / "mcp.json"
mcp_dst = Path.home() / ".workbuddy" / "mcp.json"
if not mcp_src.exists():
warn(f"MCP 配置源文件不存在: {mcp_src}")
return
if dry_run:
info(f" [DRY-RUN] 部署 MCP: {mcp_src} → {mcp_dst}")
return
# 备份现有配置
if mcp_dst.exists():
backup_dst = mcp_dst.with_suffix(".json.bak")
shutil.copy2(str(mcp_dst), str(backup_dst))
info(f" 备份旧 MCP 配置: {backup_dst}")
shutil.copy2(str(mcp_src), str(mcp_dst))
ok(f"MCP 配置已部署: {mcp_dst}")
def step_create_automations(dry_run: bool):
"""步骤5: 创建自动化任务"""
if dry_run:
info(f" [DRY-RUN] 创建 {len(AUTOMATIONS)} 个自动化任务")
return
# 自动化任务通过 WorkBuddy 自动化工具创建
# 当前方案:在 init 完成后提示用户手动在 WorkBuddy 中配置
info("自动化任务定义:")
for a in AUTOMATIONS:
info(f" 📋 {a['name']} — {a['rrule']}")
info("请在新机 WorkBuddy 中配置自动化任务,或运行 agentos sync 同步")
def step_rebuild_vector_db(dry_run: bool):
"""步骤6: 重建向量库"""
py = get_python()
script = get_sync_root() / "02_skills/kb_manager/vector_db_rebuild.py"
if not script.exists():
warn(f"向量库重建脚本不存在: {script},跳过")
return
if dry_run:
info(f" [DRY-RUN] 重建向量库: {py} {script}")
return
info("重建向量库...")
result = run([
py, str(script),
"--root", str(get_sync_root()),
"--local", str(get_local_root()),
"--track", "both",
])
if result.returncode == 0:
ok("向量库重建完成")
else:
warn(f"向量库重建失败:\n{result.stderr}")
def step_verify(dry_run: bool):
"""步骤7: 环境验证"""
if dry_run:
info(" [DRY-RUN] 环境验证跳过")
return
info("验证环境...")
# 验证 Python
py = get_python()
result = run([py, "--version"])
ok(f"Python: {result.stdout.strip()}" if result.returncode == 0 else "Python: 异常")
# 验证 oMLX
import json, urllib.request
try:
data = json.dumps({"model": "mlx-community--Qwen3-Embedding-0.6B-4bit-DWQ", "input": ["test"]}).encode()
req = urllib.request.Request(
"http://localhost:8000/v1/embeddings",
data=data,
headers={"Authorization": "Bearer 5omlx", "Content-Type": "application/json"},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=5)
if resp.status == 200:
ok("oMLX Embedding API: 正常")
else:
warn(f"oMLX Embedding API: 状态码 {resp.status}")
except Exception as e:
warn(f"oMLX Embedding API: 连接失败 ({type(e).__name__})")
# 验证 facts.db
facts_db = get_sync_root() / "04_memory/long_term/facts.db"
if facts_db.exists():
size_kb = facts_db.stat().st_size / 1024
ok(f"facts.db: {size_kb:.1f} KB")
else:
warn("facts.db 不存在(首次初始化正常)")
def step_check_path(dry_run: bool):
"""检查 agentos CLI 是否在 PATH 中,不在则添加"""
local_bin = Path.home() / ".local" / "bin"
if not local_bin.exists():
return
if not (local_bin / "agentos").exists():
return
path_str = os.environ.get("PATH", "")
local_bin_str = str(local_bin)
if local_bin_str in path_str:
info(f"agentos CLI 已在 PATH 中: {local_bin}/agentos")
return
shell_rc = None
shell = os.environ.get("SHELL", "")
if "zsh" in shell:
shell_rc = Path.home() / ".zshrc"
elif "bash" in shell:
shell_rc = Path.home() / ".bash_profile"
if shell_rc and shell_rc.exists():
export_line = f'\nexport PATH="$HOME/.local/bin:$PATH"\n'
if dry_run:
info(f"[DRY-RUN] 将添加 PATH 到 {shell_rc}")
return
with open(shell_rc, "a") as f:
f.write(export_line)
ok(f"已添加 ~/.local/bin 到 PATH ({shell_rc})")
info(f"执行以下命令生效: source {shell_rc.name}")
else:
warn(f"~/.local/bin 未在 PATH 中")
info(f"手动添加: echo 'export PATH=\"$HOME/.local/bin:\$PATH\"' >> ~/.zshrc")
def do_init(args):
"""执行 init 流程"""
dry_run = args.dry_run
skip_deps = args.skip_deps
banner()
info("开始换机一键初始化" + (" [DRY-RUN]" if dry_run else ""))
print()
# 依次执行各步骤
step_local_dirs(dry_run)
print()
step_symlinks(dry_run)
print()
if not skip_deps:
from . import check as check_mod
check_mod.install_deps(dry_run)
print()
step_install_skills(dry_run)
print()
step_deploy_mcp(dry_run)
print()
step_create_automations(dry_run)
print()
step_rebuild_vector_db(dry_run)
print()
step_verify(dry_run)
# 检查 agentos CLI 是否在 PATH 中
step_check_path(dry_run)
print()
print("=" * 60)
if dry_run:
info("DRY-RUN 完成,以上为即将执行的操作")
else:
ok("初始化完成!请重启 WorkBuddy 使更改生效。")
info("后续操作:")
info(" 1. 重启 WorkBuddy")
info(" 2. 如有旧机备份,运行: agentos restore <备份文件>")
info(" 3. 运行: agentos upgrade 统一升级所有模块")
info(" 4. 运行: agentos check 验证系统状态")
info(" 5. 运行: agentos localize 生成本机身份配置")
print()
# 自动注册到集群
if not dry_run:
info("自动注册本机到集群...")
try:
import subprocess
cluster_script = str(get_sync_root() / "05_tools" / "01_system" / "cluster_registry.py")
result = subprocess.run(
[sys.executable, cluster_script, "register"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
ok("集群注册成功")
info("查看集群状态: agentos cluster-status")
else:
warn("集群注册失败(不影响初始化)")
except Exception as e:
warn(f"集群注册异常: {e}")
def do_localize(args):
"""从模板生成本机身份配置"""
from pathlib import Path
from .utils import get_sync_root, get_local_root, info, ok, warn
sync_root = get_sync_root()
local_root = get_local_root()
identity_dir = local_root / "identity"
identity_dir.mkdir(parents=True, exist_ok=True)
templates = {
sync_root / "01_core" / "IDENTITY.tpl.md": identity_dir / "IDENTITY.md",
sync_root / "01_core" / "USER.tpl.md": identity_dir / "USER.md",
sync_root / "01_core" / "HOST_ID.tpl.md": identity_dir / "HOST_ID.md",
}
dry_run = getattr(args, "dry_run", False)
print()
print("=" * 60)
print(" 🆔 AgentOS 本机身份生成")
print("=" * 60)
print()
for src, dst in templates.items():
if not src.exists():
warn(f"模板文件不存在: {src}")
continue
if dry_run:
info(f"[DRY-RUN] 生成: {dst}")
continue
if dst.exists():
ok(f" ✓ {dst.name} (跳过,已存在)")
continue
import shutil
shutil.copy2(str(src), str(dst))
ok(f" ✓ {dst.name}")
host_id = identity_dir / "HOST_ID.md"
if host_id.exists() and not dry_run:
print()
info("HOST_ID.md 已生成,默认角色为 node(最低权限)。")
info("如需提升权限,编辑角色字段:")
print(f" 文件路径: {host_id}")
print()
print(" 可选角色: master, maintainer, node")
print(" master → 知识提纯/记忆汇总/核心维护")
print(" maintainer → 内容采集/本地记忆/提交有价值内容")
print(" node → 信息采集/素材上传(默认)")
print()
info("安全建议:先以 node 运行,确认无误后再提升权限。")
print()
ok("本机身份文件生成完成")
info(f"身份目录: {identity_dir}")
info("可手动编辑 HOST_ID.md 修改角色和能力开关")
@@ -0,0 +1,69 @@
#!/bin/bash
# agentos CLI 安装脚本
# 将 agentos 安装到 ~/.local/bin/ 并将其添加到 PATH
set -euo pipefail
AGENTOS_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="$HOME/.local/bin"
echo "========================================="
echo " AgentOS CLI 安装"
echo "========================================="
echo ""
echo "来源: $AGENTOS_DIR"
echo "目标: $INSTALL_DIR/agentos"
echo ""
# 创建 ~/.local/bin/
mkdir -p "$INSTALL_DIR"
# 创建入口脚本
cat > "$INSTALL_DIR/agentos" << 'ENTRYEOF'
#!/usr/bin/env python3
"""agentos CLI 入口:自动定位到 agent-sync 并启动主程序"""
import sys
import os
from pathlib import Path
# 尝试定位 agent-sync 根目录
CANDIDATES = [
os.environ.get("AGENT_SYNC_ROOT"),
str(Path.home() / "workbuddy-agent-os" / "agent-sync"),
str(Path.home() / "agent-os"),
]
AGENTOS_MODULE = None
for root in CANDIDATES:
if root:
module_path = Path(root) / "05_tools" / "00_setup" / "agentos"
if module_path.exists() and (module_path / "main.py").exists():
AGENTOS_MODULE = str(module_path)
break
if not AGENTOS_MODULE:
print("❌ 无法定位 AgentOS 模块。请设置环境变量 AGENT_SYNC_ROOT")
sys.exit(1)
sys.path.insert(0, str(Path(AGENTOS_MODULE).parent))
from agentos.main import main
main()
ENTRYEOF
chmod +x "$INSTALL_DIR/agentos"
# 检查 PATH 是否包含 ~/.local/bin
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo "⚠️ 请将 ~/.local/bin 添加到 PATH:"
echo ""
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
echo " source ~/.zshrc"
echo ""
fi
echo "✅ 安装完成!"
echo ""
echo "运行测试: agentos --version"
echo "帮助: agentos --help"
echo ""
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
agentos — AgentOS 统一命令入口
系统管理:
agentos init 换机一键初始化
agentos sync 双机增量同步
agentos skill <子命令> 技能管理
agentos tool <子命令> 工具管理
agentos config <子命令> 配置管理
agentos check 全系统健康检查
agentos backup 备份 agent-local/
agentos upgrade 模块升级
agentos restore <路径> 从备份还原
agentos rebuild-vector 重建向量数据库
agentos localize 生成本机身份
agentos register 注册到集群
agentos cluster-status 集群状态
agentos cluster-cleanup 清理集群
联邦管理(来自 07_matrix/scripts/agentos/plugins/):
agentos matrix 社交矩阵 — 抖音/小红书运营
agentos ave 视频工厂 — 视频制作与编辑
agentos crawl 内容采集 — 互联网内容抓取
agentos fleet 联邦管理 — 多机协同
agentos serve 服务管理 — MCP/Dashboard/调度
"""
import sys
import argparse
import importlib.util
from pathlib import Path
from .const import __version__
from .utils import banner, get_sync_root
def _load_federation_plugins():
"""从 07_matrix/scripts/agentos/ 加载联邦插件"""
sync_root = get_sync_root()
plugin_base = sync_root / "05_tools" / "07_matrix" / "scripts" / "agentos" / "plugins"
if not plugin_base.exists():
return []
# 动态加载 agentos.base 模块
agentos_pkg = plugin_base.parent
spec = importlib.util.spec_from_file_location(
"agentos.base",
agentos_pkg / "base.py",
submodule_search_locations=[str(agentos_pkg)]
)
if not spec or not spec.loader:
return []
base_mod = importlib.util.module_from_spec(spec)
sys.modules["agentos.base"] = base_mod
spec.loader.exec_module(base_mod)
# 扫描 plugins/ 下的每个 .py 文件
plugins = []
for f in sorted(plugin_base.iterdir()):
if f.name.startswith("_") or f.suffix != ".py":
continue
# 动态加载插件模块
mod_name = f"agentos.plugins.{f.stem}"
spec = importlib.util.spec_from_file_location(
mod_name, f,
submodule_search_locations=[str(plugin_base)]
)
if not spec or not spec.loader:
continue
mod = importlib.util.module_from_spec(spec)
# 让模块中的 `from agentos.base import ...` 能找到 base
sys.modules["agentos"] = type(sys)("agentos")
sys.modules["agentos"].plugins = type(sys)("plugins")
sys.modules[mod_name] = mod
spec.loader.exec_module(mod)
# 查找 AgentOSPlugin 子类
for attr_name in dir(mod):
attr = getattr(mod, attr_name)
if isinstance(attr, type) and issubclass(attr, base_mod.AgentOSPlugin) and attr is not base_mod.AgentOSPlugin:
plugins.append(attr())
return plugins, base_mod
def main():
parser = argparse.ArgumentParser(
prog="agentos",
description="AgentOS 统一命令入口 — 系统管理 + 联邦管理",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
系统管理:
agentos init / sync / skill / tool / config / check
agentos backup / restore / upgrade / rebuild-vector
agentos register / cluster-status / cluster-cleanup
联邦管理:
agentos matrix / ave / crawl / fleet / serve
参见:
agentos <command> --help
""",
)
parser.add_argument(
"--version", action="version", version=f"agentos v{__version__}"
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# ---- 系统管理命令 ----
p_init = subparsers.add_parser(
"init", help="换机一键初始化:目录/依赖/技能/MCP/自动化"
)
p_init.add_argument("--dry-run", action="store_true", help="仅预览,不执行")
p_init.add_argument("--skip-deps", action="store_true", help="跳过依赖安装")
p_sync = subparsers.add_parser(
"sync", help="双机增量同步:检测差异并更新本地 WorkBuddy"
)
p_sync.add_argument("--dry-run", action="store_true", help="仅报告差异,不执行")
p_sync.add_argument("--force", action="store_true", help="强制全量覆盖")
p_skill = subparsers.add_parser("skill", help="技能管理")
skill_sub = p_skill.add_subparsers(dest="skill_cmd", help="技能子命令")
p_skill_list = skill_sub.add_parser("list", help="列出所有可用技能")
p_skill_list.add_argument("--verbose", "-v", action="store_true", help="显示详细信息")
skill_sub.add_parser("install", help="安装/更新所有技能到 WorkBuddy")
skill_sub.add_parser("check", help="检查技能一致性")
p_skill_build = skill_sub.add_parser("build", help="打包技能为 .skill 压缩包")
p_skill_build.add_argument("skill_name", help="技能名称(对应 02_skills/ 下的目录名)")
p_skill_build.add_argument("--output", "-o", default=None, help="输出路径(默认: 07_migration/exports/)")
p_skill_search = skill_sub.add_parser("search", help="搜索技能")
p_skill_search.add_argument("keyword", help="搜索关键词")
p_skill_rm = skill_sub.add_parser("uninstall", help="从 WorkBuddy 卸载技能")
p_skill_rm.add_argument("skill_name", help="技能名称")
p_tool = subparsers.add_parser("tool", help="工具管理")
tool_sub = p_tool.add_subparsers(dest="tool_cmd", help="工具子命令")
tool_sub.add_parser("list", help="列出所有可用工具")
p_tool_trae = tool_sub.add_parser("trae", help="Trae Agent AI 编程助手")
trae_sub = p_tool_trae.add_subparsers(dest="trae_cmd", help="trae 子命令")
p_trae_run = trae_sub.add_parser("run", help="执行任务")
p_trae_run.add_argument("task", nargs="*", help="任务描述")
p_trae_run.add_argument("-p", "--provider", help="LLM 提供商")
p_trae_run.add_argument("-m", "--model", help="模型名称")
p_trae_run.add_argument("-f", "--file", help="从文件读取任务")
trae_sub.add_parser("interactive", help="交互式模式")
trae_sub.add_parser("config", help="查看配置")
trae_sub.add_parser("install", help="安装 trae-agent")
from . import config_mgr as config_mod
config_mod.setup_parser(subparsers)
p_check = subparsers.add_parser("check", help="全系统健康检查")
p_check.add_argument("--quick", action="store_true", help="快速检查(仅核心项)")
p_backup = subparsers.add_parser("backup", help="备份 agent-local/(materials + memory/raw)")
p_backup.add_argument("--output", "-o", default=None, help="输出目录(默认: 当前目录)")
p_backup.add_argument("--full", action="store_true", help="打包 agent-sync/ + agent-local/ 全量")
from . import upgrade as upgrade_mod
upgrade_mod.setup_parser(subparsers)
p_restore = subparsers.add_parser("restore", help="从备份文件还原 agent-local/")
p_restore.add_argument("backup_path", help="备份文件路径(.tar.gz)")
p_restore.add_argument("--force", action="store_true", help="覆盖现有文件")
p_vector = subparsers.add_parser("rebuild-vector", help="本地重建向量数据库(升级后执行)")
p_vector.add_argument("--track", choices=["local", "global", "both"], default="both", help="重建范围 (默认 both)")
p_vector.add_argument("--incremental", action="store_true", help="增量模式")
p_vector.add_argument("--dry-run", action="store_true", help="仅预览,不执行")
subparsers.add_parser("localize", help="从模板生成本机身份配置")
subparsers.add_parser("register", help="注册本机到集群注册表")
subparsers.add_parser("cluster-status", help="查看集群所有机器状态")
subparsers.add_parser("cluster-cleanup", help="清理过期注册条目")
# ---- 联邦管理命令(从 07_matrix/ 插件自动加载)----
fed_plugins = []
fed_base_mod = None
try:
fed_plugins, fed_base_mod = _load_federation_plugins()
for plugin in fed_plugins:
plugin.register(subparsers)
except Exception:
pass # 插件加载失败不影响系统命令
# ---- 解析 ----
args = parser.parse_args()
if not args.command:
banner()
parser.print_help()
print()
print(" 输入 agentos <command> --help 查看子命令详情")
print()
return
# ---- 路由到系统管理模块 ----
sys_commands = {
"init": (".init", "do_init"),
"sync": (".sync", "run"),
"skill": (".skill_mgr", "run"),
"tool": (".tool_mgr", "run"),
"config": (".config_mgr", "run"),
"check": (".check", "cmd_run"),
"backup": (".backup", "run"),
"upgrade": (".upgrade", "do_upgrade"),
"restore": (".backup", "run_restore"),
"rebuild-vector": (".upgrade", "do_rebuild_vector"),
"localize": (".init", "do_localize"),
"register": (".upgrade", "do_cluster_register"),
"cluster-status": (".upgrade", "do_cluster_status"),
"cluster-cleanup": (".upgrade", "do_cluster_cleanup"),
}
if args.command in sys_commands:
mod_path, func_name = sys_commands[args.command]
import importlib
mod = importlib.import_module(mod_path, "agentos")
getattr(mod, func_name)(args)
return
# ---- 路由到联邦插件 ----
for plugin in fed_plugins:
if args.command == plugin.name:
sys.exit(plugin.dispatch(args))
# 未识别的命令
print(f"未知命令: {args.command}")
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,275 @@
"""
agentos skill — 技能管理
子命令: list / install / check / build / search / uninstall
"""
import sys
import json
import shutil
import tarfile
from pathlib import Path
from datetime import datetime
from .utils import (
get_sync_root, get_local_root, get_python,
info, ok, warn, err, run, banner
)
# ──────────────────────────────────────────
# 内部函数
# ──────────────────────────────────────────
def get_skill_dirs(skills_root: Path) -> list:
"""从 skills_root 扫描所有技能目录"""
if not skills_root.exists():
return []
result = []
for d in sorted(skills_root.iterdir()):
if d.is_dir() and (d / "SKILL.md").exists():
result.append(d)
return result
def read_skill_card(skill_dir: Path) -> dict:
"""读取 SKILL_CARD.yaml 或 SKILL.md 中的元数据"""
card = {"name": skill_dir.name, "version": "?", "status": "?", "description": ""}
# 优先读 SKILL_CARD.yaml
yaml_path = skill_dir / "SKILL_CARD.yaml"
if yaml_path.exists():
try:
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f) or {}
card["version"] = data.get("version", "?")
card["status"] = data.get("status", "?")
card["description"] = data.get("description", "")
except Exception:
pass
# 从 SKILL.md 提取触发词
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
content = skill_md.read_text()
triggers = []
for line in content.split("\n"):
line = line.strip()
if line.startswith("- "):
triggers.append(line[2:])
card["triggers"] = triggers[:5] # 最多取前5个
return card
def get_workbuddy_skills_dir() -> Path:
"""获取 WorkBuddy 技能目录"""
return Path.home() / ".workbuddy" / "skills"
# ──────────────────────────────────────────
# 子命令
# ──────────────────────────────────────────
def cmd_list(verbose: bool):
"""列出所有可用技能"""
banner()
sync_root = get_sync_root()
skills_root = sync_root / "02_skills"
dirs = get_skill_dirs(skills_root)
if not dirs:
info(f"技能目录为空: {skills_root}")
return
print(f"{'技能名':<22} {'版本':<8} {'状态':<10} {'描述'}")
print("-" * 70)
for d in dirs:
card = read_skill_card(d)
desc = card.get("description") or ""
if isinstance(desc, dict):
desc = desc.get("short", "")
desc = desc[:40] if isinstance(desc, str) else ""
print(f"{d.name:<22} {card['version']:<8} {card['status']:<10} {desc}")
print(f"\n共 {len(dirs)} 个技能 ({skills_root})")
print()
if verbose:
print("触发词详情:")
for d in dirs:
card = read_skill_card(d)
triggers = card.get("triggers", [])
if triggers:
print(f" {d.name}: {' | '.join(triggers)}")
def cmd_install():
"""安装所有技能到 WorkBuddy"""
banner()
sync_root = get_sync_root()
skills_root = sync_root / "02_skills"
wb_skills = get_workbuddy_skills_dir()
wb_skills.mkdir(parents=True, exist_ok=True)
dirs = get_skill_dirs(skills_root)
if not dirs:
warn(f"没有可安装的技能: {skills_root}")
return
installed = 0
updated = 0
for d in dirs:
dest = wb_skills / d.name
if dest.exists():
shutil.rmtree(str(dest))
updated += 1
shutil.copytree(str(d), str(dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
installed += 1
ok(f"技能安装完成: 新增 {installed},更新 {updated}")
info(f"目标目录: {wb_skills}")
info("重启 WorkBuddy 后新技能将生效")
def install_all(skills_root: Path):
"""(被 init 调用) 安装所有技能"""
wb_skills = get_workbuddy_skills_dir()
wb_skills.mkdir(parents=True, exist_ok=True)
dirs = get_skill_dirs(skills_root)
for d in dirs:
dest = wb_skills / d.name
if dest.exists():
shutil.rmtree(str(dest))
shutil.copytree(str(d), str(dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
ok(f" 技能已安装: {d.name}")
def cmd_check():
"""检查技能一致性"""
banner()
sync_root = get_sync_root()
wb_skills = get_workbuddy_skills_dir()
source_dirs = {d.name: d for d in get_skill_dirs(sync_root / "02_skills")}
wb_dirs = {d.name: d for d in get_skill_dirs(wb_skills)}
# 检查源有但 WB 没有的
missing = set(source_dirs.keys()) - set(wb_dirs.keys())
if missing:
for name in sorted(missing):
warn(f"技能未安装到 WorkBuddy: {name}")
# 检查版本差异
diffs = []
for name in set(source_dirs.keys()) & set(wb_dirs.keys()):
src_card = read_skill_card(source_dirs[name])
wb_card = read_skill_card(wb_dirs[name])
if src_card["version"] != wb_card["version"]:
diffs.append((name, src_card["version"], wb_card["version"]))
if diffs:
for name, sv, wv in diffs:
info(f"版本差异: {name} (源: {sv} vs WB: {wv})")
if not missing and not diffs:
ok("所有技能一致,无差异")
else:
info("运行 agentos skill install 以同步")
def cmd_build(skill_name: str, output_path: str = None):
"""打包技能为 .skill 压缩包"""
banner()
skills_root = get_sync_root() / "02_skills"
skill_dir = skills_root / skill_name
if not skill_dir.exists() or not (skill_dir / "SKILL.md").exists():
err(f"技能不存在: {skill_name} ({skill_dir})")
sys.exit(1)
# 读取版本号
card = read_skill_card(skill_dir)
version = card["version"]
# 确定输出路径
if output_path:
out_dir = Path(output_path)
else:
out_dir = get_sync_root() / "07_migration" / "exports"
out_dir.mkdir(parents=True, exist_ok=True)
archive_name = f"{skill_name}_v{version}.tar.gz"
archive_path = out_dir / archive_name
with tarfile.open(str(archive_path), "w:gz") as tar:
tar.add(str(skill_dir), arcname=skill_name)
file_size = archive_path.stat().st_size / 1024
ok(f"技能包已创建: {archive_path} ({file_size:.1f} KB)")
info(f"安装命令: agentos skill install (放到新机后)")
info(f"手动安装: 解压到 ~/.workbuddy/skills/{skill_name}/")
def cmd_search(keyword: str):
"""搜索技能"""
banner()
skills_root = get_sync_root() / "02_skills"
keyword_lower = keyword.lower()
results = []
for d in get_skill_dirs(skills_root):
card = read_skill_card(d)
desc = card.get("description", "").lower()
name = d.name.lower()
triggers = " ".join(card.get("triggers", [])).lower()
if keyword_lower in name or keyword_lower in desc or keyword_lower in triggers:
results.append((d.name, card["version"], card.get("description", "")))
if results:
print(f"找到 {len(results)} 个匹配技能:")
for name, ver, desc in results:
print(f" {name:<20} v{ver:<6} {desc[:50]}")
else:
info(f"未找到匹配 '{keyword}' 的技能")
def cmd_uninstall(skill_name: str):
"""从 WorkBuddy 卸载技能"""
wb_dir = get_workbuddy_skills_dir() / skill_name
if not wb_dir.exists():
err(f"技能未安装: {skill_name}")
sys.exit(1)
shutil.rmtree(str(wb_dir))
ok(f"技能已卸载: {skill_name}")
info("重启 WorkBuddy 后生效")
# ──────────────────────────────────────────
# 路由
# ──────────────────────────────────────────
def run(args):
cmd = args.skill_cmd
if not cmd:
banner()
print("agentos skill 子命令: list | install | check | build | search | uninstall")
print()
return
if cmd == "list":
cmd_list(verbose=args.verbose)
elif cmd == "install":
cmd_install()
elif cmd == "check":
cmd_check()
elif cmd == "build":
cmd_build(args.skill_name, args.output)
elif cmd == "search":
cmd_search(args.keyword)
elif cmd == "uninstall":
cmd_uninstall(args.skill_name)
@@ -0,0 +1,199 @@
"""
agentos sync — 双机增量同步
检查 agent-sync/ 与本地 WorkBuddy 的差异并增量更新:
1. 技能差异检测(对比版本号和文件变化)
2. MCP 配置差异检测
3. 自动化任务差异检测
4. 增量修复差异项
"""
import sys
import json
import shutil
import hashlib
from pathlib import Path
from .utils import (
get_sync_root, get_local_root, get_python,
info, ok, warn, err, run, banner
)
from . import skill_mgr
def file_hash(path: Path) -> str:
"""计算文件 SHA256"""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()[:16]
def diff_skills(dry_run: bool) -> list:
"""检测技能差异"""
sync_root = get_sync_root()
wb_skills = Path.home() / ".workbuddy" / "skills"
source_dir = sync_root / "02_skills"
changes = []
source_dirs = {d.name: d for d in skill_mgr.get_skill_dirs(source_dir)}
wb_dirs = {d.name: d for d in skill_mgr.get_skill_dirs(wb_skills)} if wb_skills.exists() else {}
for name, src_dir in source_dirs.items():
if name not in wb_dirs:
changes.append({"type": "missing", "name": name, "action": "install"})
continue
src_card = skill_mgr.read_skill_card(src_dir)
wb_card = skill_mgr.read_skill_card(wb_dirs[name])
if src_card["version"] != wb_card["version"]:
changes.append({
"type": "version_diff",
"name": name,
"action": "update",
"src_version": src_card["version"],
"wb_version": wb_card["version"],
})
for name in wb_dirs:
if name not in source_dirs:
changes.append({"type": "extra", "name": name, "action": "uninstall"})
return changes
def diff_mcp(dry_run: bool) -> list:
"""检测 MCP 配置差异"""
sync_root = get_sync_root()
src = sync_root / "01_core" / "mcp.json"
dst = Path.home() / ".workbuddy" / "mcp.json"
changes = []
if not src.exists():
return changes
if not dst.exists():
changes.append({"type": "missing", "name": "mcp.json", "action": "deploy"})
else:
src_hash = file_hash(src)
dst_hash = file_hash(dst)
if src_hash != dst_hash:
changes.append({"type": "content_diff", "name": "mcp.json", "action": "update"})
return changes
def diff_automations(dry_run: bool) -> list:
"""检测自动化任务差异"""
sync_root = get_sync_root()
automations_file = sync_root / "01_core" / "automations.yaml"
if not automations_file.exists():
return []
return [{"type": "info", "name": "automations.yaml", "action": "需手动在 WorkBuddy 中比对"}]
def apply_changes(changes: list, dry_run: bool, force: bool):
"""执行差异修复"""
if dry_run:
return
for ch in changes:
action = ch["action"]
if action == "install":
info(f" 安装技能: {ch['name']}")
src_dir = get_sync_root() / "02_skills" / ch["name"]
skill_mgr.install_all(get_sync_root() / "02_skills")
elif action == "update":
info(f" 更新技能: {ch['name']} ({ch.get('src_version', '?')})")
skill_mgr.install_all(get_sync_root() / "02_skills")
elif action == "uninstall":
warn(f" agent-sync 中已移除: {ch['name']}(建议手动清理 WorkBuddy)")
elif action == "deploy":
info(f" 部署 MCP 配置")
src = get_sync_root() / "01_core" / "mcp.json"
dst = Path.home() / ".workbuddy" / "mcp.json"
if dst.exists():
dst.rename(dst.with_suffix(".json.syncbak"))
shutil.copy2(str(src), str(dst))
ok(" MCP 配置已更新")
elif action == "update" and ch["name"] == "mcp.json":
info(f" 更新 MCP 配置")
src = get_sync_root() / "01_core" / "mcp.json"
dst = Path.home() / ".workbuddy" / "mcp.json"
dst.rename(dst.with_suffix(".json.syncbak"))
shutil.copy2(str(src), str(dst))
ok(" MCP 配置已更新")
def run(args):
dry_run = args.dry_run
force = args.force
banner()
info("双机增量同步" + (" [DRY-RUN]" if dry_run else ""))
print()
# 1. 检测技能差异
print("📦 技能差异:")
skill_changes = diff_skills(dry_run)
if not skill_changes:
ok(" 无差异")
else:
for ch in skill_changes:
action_icon = {"install": "➕", "update": "🔄", "uninstall": "🗑️"}.get(ch["action"], "?")
if ch["type"] == "missing":
warn(f" {action_icon} 新增技能: {ch['name']}")
elif ch["type"] == "version_diff":
info(f" {action_icon} 版本变更: {ch['name']} ({ch['wb_version']} → {ch['src_version']})")
elif ch["type"] == "extra":
warn(f" {action_icon} 多余的: {ch['name']}(源目录已移除)")
print()
# 2. MCP 差异
print("🔧 MCP 配置差异:")
mcp_changes = diff_mcp(dry_run)
if not mcp_changes:
ok(" 无差异")
else:
for ch in mcp_changes:
if ch["action"] == "deploy":
warn(f" 缺失: {ch['name']}(待部署)")
elif ch["action"] == "update":
info(f" 内容变更: {ch['name']}(待更新)")
print()
# 3. 自动化差异
print("⏰ 自动化任务差异:")
auto_changes = diff_automations(dry_run)
if not auto_changes:
ok(" 无自动化配置文件(无差异检测)")
else:
for ch in auto_changes:
info(f" {ch['name']}: {ch['action']}")
print()
# 汇总
all_changes = skill_changes + mcp_changes + [c for c in auto_changes if c["action"] != "需手动在 WorkBuddy 中比对"]
if not all_changes:
ok("完全一致,无需同步")
return
print(f"共 {len(all_changes)} 项差异")
if dry_run:
info("添加 --force 参数执行同步")
else:
# 执行同步
info("执行同步...")
apply_changes(all_changes, dry_run, force)
ok("同步完成,请重启 WorkBuddy 使更改生效")
@@ -0,0 +1,177 @@
"""
agentos tool — 工具管理
子命令: list, trae
"""
import os
import subprocess
import sys
from pathlib import Path
from .utils import get_sync_root, info, ok, warn, err, banner
TOOL_CATEGORIES = {
"00_setup": "安装部署",
"01_system": "系统诊断与健康检查",
"02_browser": "浏览器自动化",
"03_ocr": "OCR 识别",
"04_media": "媒体处理",
"05_crawl": "网页抓取与爬虫",
"06_mobile": "移动端工具",
"07_matrix": "矩阵养号",
"08_trae_agent": "Trae AI 编程助手",
}
def _get_trae_script() -> Path:
"""查找 trae_agent.sh 脚本路径"""
sync_root = get_sync_root()
candidates = [
sync_root / "05_tools" / "08_trae_agent" / "trae_agent.sh",
sync_root / "05_tools" / "08_trae_agent" / "trae_agent.sh",
]
for p in candidates:
if p.exists():
return p
# Fallback: 直接使用 agent-local 路径
fallback = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "trae-agent"
script = fallback.parent.parent / "agent-sync" / "05_tools" / "08_trae_agent" / "trae_agent.sh"
if script.exists():
return script
return candidates[0]
def cmd_list():
"""列出所有可用工具"""
banner()
tools_root = get_sync_root() / "05_tools"
if not tools_root.exists():
warn(f"工具目录不存在: {tools_root}")
return
total = 0
for category_dir in sorted(tools_root.iterdir()):
if not category_dir.is_dir() or category_dir.name.startswith("."):
continue
cat_name = TOOL_CATEGORIES.get(category_dir.name, category_dir.name)
scripts = sorted(category_dir.rglob("*.py")) + sorted(category_dir.rglob("*.sh"))
# 过滤掉 __pycache__ 和 __init__.py
scripts = [s for s in scripts if "__pycache__" not in s.parts and s.name != "__init__.py"]
if not scripts:
continue
print(f"\n{cat_name} ({category_dir.name}/):")
for s in scripts:
rel = s.relative_to(tools_root)
# 显示简要描述(读取文件第一行注释)
desc = ""
try:
with open(s) as f:
first_line = f.readline().strip()
if first_line.startswith("#"):
desc = first_line.lstrip("# ").strip()
elif first_line.startswith('"""'):
desc = s.read_text().split('"""')[1].strip().split("\n")[0].strip()
except Exception:
pass
desc = desc[:50] if desc else ""
print(f" 📄 {rel}")
if desc:
print(f" {desc}")
total += 1
print(f"\n共 {total} 个工具脚本")
print()
def cmd_trae_install():
"""安装 trae-agent"""
sync_root = get_sync_root()
install_script = sync_root / "05_tools" / "08_trae_agent" / "install_trae_agent.sh"
if not install_script.exists():
err(f"安装脚本不存在: {install_script}")
return
info("正在安装 trae-agent...")
result = subprocess.run(["bash", str(install_script)], cwd=str(install_script.parent))
if result.returncode == 0:
ok("trae-agent 安装完成")
else:
err("安装失败")
def cmd_trae_run(args):
"""执行 trae-agent 任务"""
script = _get_trae_script()
if not script.exists():
err(f"trae_agent.sh 不存在: {script}")
err("请先运行: agentos tool trae install")
return
# 绕过系统代理(macOS 系统代理端口 6478 会拦截本地 LLM 请求)
env = {**subprocess._clean_environ() if hasattr(subprocess, '_clean_environ') else {}, **os.environ}
env["NO_PROXY"] = "localhost,127.0.0.1,::1"
env["no_proxy"] = "localhost,127.0.0.1,::1"
cmd = ["bash", str(script), "run"]
if args.provider:
cmd.extend(["-p", args.provider])
if args.model:
cmd.extend(["-m", args.model])
if args.file:
cmd.extend(["-f", args.file])
if args.task:
cmd.append(" ".join(args.task))
result = subprocess.run(cmd, env=env)
sys.exit(result.returncode)
def cmd_trae_interactive():
"""启动 trae-agent 交互模式"""
script = _get_trae_script()
if not script.exists():
err(f"trae_agent.sh 不存在: {script}")
err("请先运行: agentos tool trae install")
return
subprocess.run(["bash", str(script), "interactive"])
def cmd_trae_config():
"""查看 trae-agent 配置"""
script = _get_trae_script()
if not script.exists():
err("trae-agent 未安装")
return
subprocess.run(["bash", str(script), "show-config"])
def run(args):
cmd = args.tool_cmd
if not cmd:
banner()
print("agentos tool 子命令: list, trae")
print()
return
if cmd == "list":
cmd_list()
elif cmd == "trae":
trae_cmd = getattr(args, "trae_cmd", None)
if not trae_cmd:
banner()
print("agentos tool trae 子命令: run, interactive, config, install")
print()
return
if trae_cmd == "run":
cmd_trae_run(args)
elif trae_cmd == "interactive":
cmd_trae_interactive()
elif trae_cmd == "config":
cmd_trae_config()
elif trae_cmd == "install":
cmd_trae_install()
@@ -0,0 +1,473 @@
"""
agentos upgrade — 统一模块升级引擎 (v1.0.0)
扫描 agent-sync/ 下所有模块,对每个模块执行标准化 4 步升级:
1. 检测模块状态(有 MODULE.md?install.sh?)
2. 运行 install.sh(建目录 + 写 local.yaml)
3. 安装 Python 依赖(pip install -r requirements.txt)
4. 运行 check 脚本(验证配置 + 环境完整性)
用法:
agentos upgrade 全量升级所有模块
agentos upgrade --module matrix 只升级指定模块
最后更新: 2026-05-01
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional
from .const import __agent_sync_root__, __agent_local_root__
# 模块类型
MODULE_TYPES = {
"02_skills": "skill",
"05_tools": "tool",
"03_knowledge": "knowledge",
"04_memory": "memory",
}
# 模块扫描目录(按优先级排序)
SCAN_DIRS = ["05_tools", "02_skills"]
class ModuleInfo:
"""解析后的模块信息"""
def __init__(self, path: Path, rel_path: str):
self.path = path # 模块目录绝对路径
self.rel_path = rel_path # 相对 agent-sync/ 的路径
self.name = path.name
self.type = self._detect_type()
self.has_module_md = (path / "MODULE.md").exists()
self.has_install_sh = (path / "install.sh").exists()
self.has_requirements = (path / "requirements.txt").exists()
self.has_check_sh = (path / "check.sh").exists()
self.has_config_template = (path / "config_template").exists()
self.local_dir = self._calc_local_dir()
def _detect_type(self) -> str:
for prefix, mtype in MODULE_TYPES.items():
if self.rel_path.startswith(prefix):
return mtype
return "unknown"
def _calc_local_dir(self) -> Optional[Path]:
"""计算对应的 agent-local 目录路径"""
if self.type == "tool":
# 05_tools/07_matrix → agent-local/tools/matrix
parts = self.rel_path.split("/")
if len(parts) >= 2:
return Path(__agent_local_root__) / "tools" / "_".join(parts[1:])
return None
def __repr__(self):
return f"<{self.type}:{self.name}>"
# ══════════════════════════════════════════════════════════════
# 模块扫描
# ══════════════════════════════════════════════════════════════
def scan_modules(target: str = None) -> list:
"""扫描 agent-sync/ 下所有可用模块
参数:
target: 指定模块名(可选)
返回:
list[ModuleInfo]: 模块列表
"""
sync_root = Path(__agent_sync_root__)
modules = []
for scan_dir in SCAN_DIRS:
scan_path = sync_root / scan_dir
if not scan_path.exists():
continue
for item in sorted(scan_path.iterdir()):
if not item.is_dir():
continue
# 跳过隐藏目录和 __pycache__
if item.name.startswith(".") or item.name == "__pycache__":
continue
# 如果是 05_tools,跳过 00_setup(agentos 自身)和单文件目录
if scan_dir == "05_tools":
parts = item.name.split("_", 1)
if len(parts) < 2 or not parts[0].isdigit():
continue
rel = f"{scan_dir}/{item.name}"
module = ModuleInfo(item, rel)
if target and target != module.name:
continue
modules.append(module)
return modules
# ══════════════════════════════════════════════════════════════
# 升级步骤
# ══════════════════════════════════════════════════════════════
def _run(cmd: list, cwd: Path = None, timeout: int = 120) -> tuple:
"""运行命令并返回 (ok, stdout_preview)"""
try:
result = subprocess.run(
cmd, cwd=cwd or Path.cwd(),
capture_output=True, text=True, timeout=timeout
)
ok = result.returncode == 0
out = (result.stdout or result.stderr or "").strip()[:200]
return ok, out
except Exception as e:
return False, str(e)
def step_git_pull() -> tuple:
"""Step 0: 拉取最新代码"""
sync_root = Path(__agent_sync_root__)
if not (sync_root / ".git").exists():
return "skipped", "非 Git 仓库,跳过"
ok, out = _run(["git", "pull", "origin", "main"], cwd=sync_root)
return ("ok" if ok else "fail"), out
def step_install(module: ModuleInfo) -> tuple:
"""Step 1: 运行 install.sh(如果有)"""
install_path = module.path / "install.sh"
if not install_path.exists():
return "skipped", "无 install.sh"
# 传入 managed Python 路径(所有机器统一版本,避免差异)
from .utils import get_python
env = os.environ.copy()
env["WORKBUDDY_PYTHON"] = get_python()
try:
result = subprocess.run(
["bash", str(install_path)],
capture_output=True, text=True, timeout=120,
env=env
)
ok = result.returncode == 0
out = (result.stdout or result.stderr or "").strip()[:200]
status = "ok" if ok else "fail"
return status, out
except subprocess.TimeoutExpired:
return "fail", "安装超时(网络慢?可稍后手动安装)"
except Exception as e:
return "fail", str(e)
def step_pip(module: ModuleInfo) -> tuple:
"""Step 2: 安装 Python 依赖"""
req_path = module.path / "requirements.txt"
if not req_path.exists():
return "skipped", "无 requirements.txt"
# 使用统一 managed Python(所有机器保持同一版本,避免差异)
from .utils import get_python
python = get_python()
ok, out = _run([python, "-m", "pip", "install", "-r", str(req_path), "-q"])
status = "ok" if ok else "fail"
return status, out
def step_check(module: ModuleInfo) -> tuple:
"""Step 3: 运行 check 脚本(如果有)"""
check_path = module.path / "check.sh"
if not check_path.exists():
return "skipped", "无 check.sh"
ok, out = _run(["bash", str(check_path)])
status = "ok" if ok else "fail"
return status, out
def step_environ(module: ModuleInfo) -> list:
"""Step 4: 环境完整性检查(不执行,只报告)"""
notes = []
# 检查 local 目录
if module.local_dir:
if not module.local_dir.exists():
notes.append("⚠️ agent-local 目录未创建,需要 install.sh")
else:
subdirs = ["config", "data", "logs", "profiles"]
missing = [d for d in subdirs if not (module.local_dir / d).exists()]
if missing:
notes.append(f"⚠️ 缺少子目录: {', '.join(missing)}")
# 检查 config_template
if module.has_config_template:
target_conf = module.local_dir / "config" if module.local_dir else None
if target_conf:
has_config = any(target_conf.glob("*.yaml")) or any(target_conf.glob("*.yml"))
if not has_config:
notes.append("📋 需配置: cp config_template/*.yaml → agent-local/config/")
return notes
# ══════════════════════════════════════════════════════════════
# 主升级流程
# ══════════════════════════════════════════════════════════════
def do_upgrade(args):
"""执行升级流程"""
target = getattr(args, "module", None)
dry_run = getattr(args, "dry_run", False)
print(f"\n{'='*60}")
print(f" 🔄 AgentOS 统一升级")
print(f" {'(dry-run)' if dry_run else '(执行模式)'}")
print(f"{'='*60}\n")
# Step 0: 拉取代码
print(f"▶ git pull")
status, detail = step_git_pull()
print(f" {_icon(status)} {detail}")
# 扫描模块
modules = scan_modules(target)
if not modules:
print(f"\n ❌ 未找到模块" + (f" (匹配: {target})" if target else ""))
return
print(f"\n 扫描到 {len(modules)} 个模块:")
for m in modules:
print(f" {m.type:6s} {m.name:25s} {m.rel_path}")
print()
# 逐模块升级
results = {}
for module in modules:
# 判断模块层级
has_local_needs = module.has_install_sh or module.has_requirements or module.has_config_template
if not has_local_needs:
# L1 纯代码模块
print(f" 📦 {module.name} ({module.type}) — 纯代码模块,无需本地部署")
module_results = {"install": ("skipped", ""), "pip": ("skipped", ""), "check": ("skipped", "")}
print(f" install: ⏭️ 无本地依赖")
print(f" pip: ⏭️ 无 Python 依赖")
print(f" check: ⏭️ 无检查脚本")
else:
print(f"{'─'*60}")
print(f" 📦 {module.name} ({module.type})")
print(f"{'─'*60}")
module_results = {}
# Step 1: install.sh
s, d = step_install(module)
module_results["install"] = (s, d)
print(f" install: {_icon(s)} {d[:80]}")
# Step 2: pip install
s, d = step_pip(module)
module_results["pip"] = (s, d)
print(f" pip: {_icon(s)} {d[:80]}")
# Step 3: check.sh
s, d = step_check(module)
module_results["check"] = (s, d)
print(f" check: {_icon(s)} {d[:80]}")
# Step 4: 环境检查
notes = step_environ(module)
module_results["environ"] = notes
for n in notes:
print(f" {n}")
results[module.name] = module_results
print()
# 如果有 Skill 模块,自动注册到 WorkBuddy
has_skills = any(m.type == "skill" for m in modules)
if has_skills and not dry_run:
print(f"{'─'*60}")
print(f" 🔗 注册技能到 WorkBuddy...")
from . import skill_mgr
try:
skill_mgr.run(argparse.Namespace(skill_cmd="install"))
print(f" ✅ 技能注册完成")
except Exception as e:
print(f" ⚠️ 技能注册失败: {e}")
print(f" 可稍后手动执行: agentos skill install")
print()
# 自动注册到集群
if not dry_run:
print(f"{'─'*60}")
print(f" 📡 自动注册到集群...")
try:
import subprocess
sync_root = get_sync_root()
script = str(sync_root / "05_tools" / "01_system" / "cluster_registry.py")
result = subprocess.run(
[sys.executable, script, "register"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
print(f" ✅ 集群注册完成")
else:
print(f" ⚠️ 集群注册失败: {result.stderr.strip()}")
except Exception as e:
print(f" ⚠️ 集群注册异常: {e}")
print(f" 可稍后手动执行: agentos register")
print()
# 汇总报告
_print_report(results, modules)
def _icon(status: str) -> str:
return {"ok": "✅", "fail": "❌", "skipped": "⏭️"}.get(status, "❓")
def _print_report(results: dict, modules: list):
"""打印升级报告"""
print(f"{'='*60}")
print(f" 📋 升级报告")
print(f"{'='*60}\n")
# 按层级分组统计
l1_count = sum(1 for m in modules if not m.has_install_sh and not m.has_requirements)
l2_count = sum(1 for m in modules if m.has_install_sh or m.has_requirements)
ok_count = 0
warn_count = 0
for module in modules:
r = results.get(module.name, {})
install_s = r.get("install", ("skipped", ""))[0]
pip_s = r.get("pip", ("skipped", ""))[0]
check_s = r.get("check", ("skipped", ""))[0]
notes = r.get("environ", [])
has_local = module.has_install_sh or module.has_requirements
module_ok = all(s == "ok" or s == "skipped" for s in [install_s, pip_s, check_s])
if module_ok:
ok_count += 1
else:
warn_count += 1
if has_local:
print(f" 📦 {module.name:25s} {_icon(install_s)} install {_icon(pip_s)} pip {_icon(check_s)} check")
else:
print(f" 📦 {module.name:25s} ⏭️ 纯代码模块(无本地依赖)")
for n in notes:
print(f" {'':25s} {n}")
print(f"\n L1 纯代码模块: {l1_count} 个(自动跳过,无需部署)")
print(f" L2 本地数据模块: {l2_count} 个(已执行 install/pip/check)")
print()
print(f"{'-'*60}")
if warn_count == 0:
print(f" ✅ 全部 {ok_count} 个模块检查完成")
else:
print(f" ⚠️ {ok_count} 个正常, {warn_count} 个需关注")
print(f"{'-'*60}")
print()
# 手动操作提示
manual_steps = []
for module in modules:
if module.name == "matrix":
manual_steps.append(f" ☐ Matrix: 配置 {__agent_local_root__}/tools/matrix/config/accounts.yaml")
manual_steps.append(f" ☐ Matrix: 运行 switch_account.py --method profile 重新登录")
if manual_steps:
print(f" 需手动操作:")
for s in manual_steps:
print(s)
print()
def setup_parser(subparsers):
"""向主 CLI 注册 upgrade 子命令"""
p = subparsers.add_parser("upgrade", help="统一升级:拉取代码 + 安装依赖 + 环境检查")
p.add_argument("--module", "-m", type=str, default=None,
help="只升级指定模块(如 matrix)")
p.add_argument("--dry-run", action="store_true",
help="仅预览,不执行实际操作")
p.set_defaults(func=do_upgrade)
def do_rebuild_vector(args):
"""重建本地向量数据库"""
import subprocess as sp
from pathlib import Path
from .utils import get_sync_root, get_local_root, get_python
sync_root = get_sync_root()
local_root = get_local_root()
python = get_python()
track = getattr(args, "track", "both")
dry_run = getattr(args, "dry_run", False)
incremental = getattr(args, "incremental", False)
script = sync_root / "02_skills" / "kb_manager" / "vector_db_rebuild.py"
if not script.exists():
print(f"❌ 向量重建脚本不存在: {script}")
return
cmd = [
str(python),
str(script),
"--root", str(sync_root),
"--local", str(local_root),
"--track", track,
]
if incremental:
cmd.append("--incremental")
if dry_run:
cmd.append("--dry-run")
print(f"🔄 重建向量数据库 ({track})")
result = sp.run(cmd, cwd=str(sync_root))
if result.returncode == 0:
print("✅ 向量数据库重建完成")
else:
print(f"❌ 向量数据库重建失败 (rc={result.returncode})")
def _run_cluster_script(args, subcommand: str):
"""运行集群注册表脚本"""
import subprocess as sp
from pathlib import Path
from .utils import get_sync_root
sync_root = get_sync_root()
script = sync_root / "05_tools" / "01_system" / "cluster_registry.py"
if not script.exists():
print(f"❌ 集群脚本不存在: {script}")
return
result = sp.run(
[sys.executable, str(script), subcommand],
cwd=str(sync_root)
)
return result.returncode
def do_cluster_register(args):
"""注册本机到集群"""
_run_cluster_script(args, "register")
def do_cluster_status(args):
"""查看集群状态"""
_run_cluster_script(args, "status")
def do_cluster_cleanup(args):
"""清理过期注册条目"""
_run_cluster_script(args, "cleanup")
@@ -0,0 +1,108 @@
"""
工具函数:路径解析、shell 执行、颜色输出
"""
import os
import sys
import subprocess
from pathlib import Path
from .const import __agent_sync_root__, __agent_local_root__
def get_sync_root() -> Path:
"""获取 agent-sync 根目录"""
env_root = os.environ.get("AGENT_SYNC_ROOT")
if env_root:
return Path(env_root)
return Path(__agent_sync_root__).expanduser()
def get_local_root() -> Path:
"""获取 agent-local 根目录"""
env_root = os.environ.get("AGENT_LOCAL_ROOT")
if env_root:
return Path(env_root)
return Path(__agent_local_root__).expanduser()
def get_python() -> str:
"""获取 WorkBuddy 管理的 Python 路径"""
candidates = [
os.environ.get("AGENTOS_PYTHON"),
str(Path.home() / ".workbuddy/binaries/python/envs/agent-os/bin/python3"),
str(Path.home() / ".workbuddy/binaries/python/versions/3.13.12/bin/python3"),
"python3",
]
for c in candidates:
if c and Path(c).exists():
return c
return "python3"
def get_node() -> str:
"""获取 WorkBuddy 管理的 node 路径"""
candidates = [
str(Path.home() / ".workbuddy/binaries/node/versions/22.12.0/bin/node"),
"node",
]
for c in candidates:
if c and Path(c).exists():
return c
return "node"
def get_npx() -> str:
"""获取 WorkBuddy 管理的 npx 路径"""
node_dir = Path.home() / ".workbuddy/binaries/node/versions/22.12.0/bin"
npx_path = node_dir / "npx"
if npx_path.exists():
return str(npx_path)
return "npx"
def run(cmd: list, **kwargs) -> subprocess.CompletedProcess:
"""运行命令并返回结果"""
default = {"capture_output": True, "text": True, "timeout": 120}
default.update(kwargs)
return subprocess.run(cmd, **default)
def color(text: str, color_name: str = "blue") -> str:
"""终端颜色输出"""
colors = {
"red": "\033[91m",
"green": "\033[92m",
"yellow": "\033[93m",
"blue": "\033[94m",
"cyan": "\033[96m",
"reset": "\033[0m",
}
return f"{colors.get(color_name, '')}{text}{colors['reset']}"
def info(msg: str):
print(f"{color('[INFO]', 'blue')} {msg}")
def ok(msg: str):
print(f"{color('[OK]', 'green')} {msg}")
def warn(msg: str):
print(f"{color('[WARN]', 'yellow')} {msg}")
def err(msg: str):
print(f"{color('[ERROR]', 'red')} {msg}")
def banner():
"""显示欢迎横幅"""
print()
print("=" * 60)
print(f" AgentOS 系统管理 CLI v1.0.0")
print(f" Synced: {get_sync_root()}")
print(f" Local: {get_local_root()}")
print("=" * 60)
print()
@@ -0,0 +1,185 @@
# guardd v2.3.0 — 联邦守护进程 (WPRA v2.0)
> **架构**: WPRA v2.0 — 每台机器只写自己的命名空间,永不冲突
> **版本**: 2.3.0 | **更新**: 2026-05-31
> 适用: 所有 AgentOS 联邦机器
---
## 架构变化 (v2.3.0 关键升级)
v2.3.0 从 `git add -A` 切换到 **WPRA (Write Partitioned, Read Aggregated)** 模型:
| 旧架构 (≤2.2.x) | 新架构 (≥2.3.0) |
|:-----------------|:-----------------|
| `git add -A` 全量提交 → 冲突 | `git add <本机文件>` → 精确提交 |
| 全员写 `_registry.json` → 覆盖冲突 | guardd 不写聚合文件,只写自己的命名空间 |
| 单文件 `accounts_registry.yaml` | 每台机器 `machines/{uid}/accounts.yaml` |
| 心跳写到 `status/live/` | 同时写到 `machines/{uid}/heartbeat.json` |
**每台机器只操作自己的命名空间:**
```
machines/{MACHINE_UID}/
├── MACHINE.yaml ← 身份声明(首次写入,终身只读)
├── heartbeat.json ← 心跳(含 file_version + updated_at)
└── events/{date}.jsonl ← 事件日志(append-only)
```
**不再由 guardd 写入的文件**(已移交给 Dashboard 单实例管理):
- `status/live/_registry.json` — 安全删除,无冲突风险
---
## 前置条件
- 本机能访问 Gitee (git@gitee.com:babycalf/mac-agent-os.git)
- 已配置 SSH 密钥 (有推送权限)
- Python 3 可用
---
## 安装步骤
### 1. 克隆/拉取仓库
```bash
# 首次 (如果还没有仓库)
git clone [email protected]:babycalf/mac-agent-os.git ~/workbuddy-agent-os/agent-sync
# 已有仓库
cd ~/workbuddy-agent-os/agent-sync && git pull
```
### 2. 创建本地目录
```bash
mkdir -p ~/workbuddy-agent-os/agent-local/identity
mkdir -p ~/workbuddy-agent-os/agent-local/runtime/guardd
```
### 3. 首次运行测试
```bash
cd ~/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd
python3 guardd.py --once
```
如果输出 `心跳已上报` 表示成功。此时:
- 本机 UID 已生成 → `agent-local/identity/machine_uid`
- 本机 hostname 已缓存 → `agent-local/identity/cached_hostname`
- 心跳已写入 (旧) → `cross_machine/status/live/{UID}.json`
- 心跳已写入 (新) → `cross_machine/machines/{UID}/heartbeat.json` ✅ WPRA v2.0
- 身份声明已创建 → `cross_machine/machines/{UID}/MACHINE.yaml`
- 数据已推送到 Git (精确 add,非 `-A`)
### 4. 验证 Dashboard
```bash
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
python3 run.py 9988
```
浏览器打开 `http://本机IP:9988` → 看到所有机器状态
### 5. 配置自动启动 (macOS launchd)
创建 plist 文件:
```bash
cat > ~/Library/LaunchAgents/com.agentos.guardd.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentos.guardd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/guardd.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync</string>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>
EOF
# 加载启动
launchctl load ~/Library/LaunchAgents/com.agentos.guardd.plist
```
### 6. 配置 Dashboard 自动启动
```bash
cat > ~/Library/LaunchAgents/com.agentos.dashboard.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentos.dashboard</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync/05_tools/10_dashboard/run.py</string>
<string>9988</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-sync/05_tools/10_dashboard</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/dashboard_stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/$(whoami)/workbuddy-agent-os/agent-local/runtime/guardd/dashboard_stderr.log</string>
</dict>
</dict>
</plist>
EOF
launchctl load ~/Library/LaunchAgents/com.agentos.dashboard.plist
```
---
## 版本管理
**版本文件**: `cross_machine/guardd-required-version.txt`
guardd 每次启动时会检查版本号。如果当前版本低于要求版本:
1. 自动执行 `git pull` 拉取最新代码
2. 提示重启
**如果版本不匹配, 手动更新**:
```bash
cd ~/workbuddy-agent-os/agent-sync && git pull
# 然后重启 guardd
launchctl unload ~/Library/LaunchAgents/com.agentos.guardd.plist
launchctl load ~/Library/LaunchAgents/com.agentos.guardd.plist
```
---
## 常见问题
| 问题 | 解决方法 |
|------|---------|
| Git push 权限不足 | 检查 SSH key 是否添加到 Gitee |
| 启动报错 | 检查 stdout.log/stderr.log |
| Dashboard 看不到其他机器 | 点击左侧 **[🔄 同步]** 执行 git pull |
| 磁盘占用 | `agent-local/runtime/guardd/` 下的日志会自动轮转 |
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentos.chrome-debug</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/chengzige/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/scripts/chrome_debug.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/chengzige/workbuddy-agent-os/agent-local/runtime/chrome-debug.log</string>
<key>StandardErrorPath</key>
<string>/Users/chengzige/workbuddy-agent-os/agent-local/runtime/chrome-debug.err</string>
</dict>
</plist>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentos.guardd</string>
<key>ProgramArguments</key>
<array>
<string>__HOME__/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/launch.sh</string>
<string>05_tools/00_setup/guardd/guardd.py</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>__HOME__/workbuddy-agent-os/agent-local/runtime/guardd/stdout.log</string>
<key>StandardErrorPath</key>
<string>__HOME__/workbuddy-agent-os/agent-local/runtime/guardd/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:__HOME__/.workbuddy/binaries/python/versions/3.13.12/bin</string>
<key>GUARDD_DASHBOARD_URL</key>
<string>http://localhost:9988</string>
</dict>
<key>ThrottleInterval</key>
<integer>30</integer>
</dict>
</plist>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════
# guardd — 自动安装/修复脚本
# 适用: 任何联邦机器(自动适配当前用户)
# 用法: bash install_guardd.sh
# ═══════════════════════════════════════════════════════════
set -e
USER_HOME="$HOME"
USER_NAME="$(whoami)"
AGENT_SYNC="$HOME/workbuddy-agent-os/agent-sync"
AGENT_LOCAL="$HOME/workbuddy-agent-os/agent-local"
GUARDD_DIR="$AGENT_SYNC/05_tools/00_setup/guardd"
GUARDD_PY="$GUARDD_DIR/guardd.py"
# 检测 WorkBuddy 管理的 Python
PYTHON_BIN=""
for p in "$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3" \
"$HOME/.workbuddy/binaries/python/versions/3.13.12/bin/python3" \
"/usr/bin/python3"; do
if [ -x "$p" ]; then
PYTHON_BIN="$p"
break
fi
done
if [ -z "$PYTHON_BIN" ]; then
echo "❌ 未找到可用的 Python 3"
exit 1
fi
echo "════════════════════════════════════════════"
echo " guardd 安装脚本"
echo " 用户: $USER_NAME"
echo " 路径: $AGENT_SYNC"
echo " Python: $PYTHON_BIN"
echo "════════════════════════════════════════════"
echo ""
# ── 1. 创建必要目录 ──
echo "[1/5] 创建运行时目录..."
mkdir -p "$AGENT_LOCAL/identity"
mkdir -p "$AGENT_LOCAL/runtime/guardd"
# ── 2. 修复 guardd.py 的 logger bug(已固化的版本会从仓库同步) ──
echo "[2/5] 检查 guardd.py..."
if [ ! -f "$GUARDD_PY" ]; then
echo "❌ guardd.py 不存在 — 请先 git pull"
exit 1
fi
# ── 3. 生成 plist ──
echo "[3/5] 生成 launchd plist..."
PLIST_PATH="$HOME/Library/LaunchAgents/com.agentos.guardd.plist"
cat > "$PLIST_PATH" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentos.guardd</string>
<key>ProgramArguments</key>
<array>
<string>${PYTHON_BIN}</string>
<string>${GUARDD_PY}</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>${AGENT_LOCAL}/runtime/guardd/stdout.log</string>
<key>StandardErrorPath</key>
<string>${AGENT_LOCAL}/runtime/guardd/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${HOME}/.workbuddy/binaries/python/versions/3.13.12/bin</string>
<!-- GUARDD_DASHBOARD_URL 已废弃: 改为读 agent-local/config.yaml 中的 dashboard_url
<key>GUARDD_DASHBOARD_URL</key>
<string>http://localhost:9988</string>
-->
</dict>
<key>KeepAlive</key>
<false/>
<key>ThrottleInterval</key>
<integer>300</integer>
</dict>
</plist>
EOF
echo " ✅ plist 已生成: $PLIST_PATH"
# ── 4. 卸载旧服务 ──
echo "[4/5] 卸载旧 guardd 服务..."
launchctl bootout gui/$(id -u)/com.agentos.guardd 2>/dev/null || true
# 兼容旧版 launchctl
launchctl unload "$PLIST_PATH" 2>/dev/null || true
echo " ✅ 已卸载"
# ── 5. 加载新服务 ──
echo "[5/5] 加载 guardd 服务..."
launchctl bootstrap gui/$(id -u) "$PLIST_PATH" 2>/dev/null || launchctl load "$PLIST_PATH"
echo " ✅ 已加载"
echo ""
echo "════════════════════════════════════════════"
echo " ✅ 安装完成!"
echo "════════════════════════════════════════════"
echo ""
echo "查看状态: launchctl print gui/$(id -u)/com.agentos.guardd | grep state"
echo "查看日志: tail -f $AGENT_LOCAL/runtime/guardd/stdout.log"
echo ""
echo "如需卸载:"
echo " launchctl bootout gui/$(id -u)/com.agentos.guardd"
echo " rm ~/Library/LaunchAgents/com.agentos.guardd.plist"
@@ -0,0 +1,12 @@
#!/bin/bash
# AgentOS launchd wrapper — 自动解析 $HOME 路径,适配多机
# 用法: launch.sh <script_relative_path> [args...]
# 示例: launch.sh 05_tools/00_setup/guardd/guardd.py
SCRIPT_REL="$1"
shift
PYTHON="$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3"
SCRIPT="$HOME/workbuddy-agent-os/agent-sync/$SCRIPT_REL"
exec "$PYTHON" "$SCRIPT" "$@"
@@ -0,0 +1,119 @@
"""
account_monitor.py — 本机账号登录状态采集 (guardd 模块)
职责:
- 读取本机 identities/ 目录下所有账号的 cookies.sqlite
- 返回 {account_id: status} 映射
- 每次调用实时检测(不缓存)
状态定义:
logged_in — cookite 文件存在且含 session cookie
no_cookie — 身份目录存在但无 cookies.sqlite 文件
empty_cookie — cookies.sqlite 文件小于 100 字节
no_session — cookies.sqlite 存在但无 session 类型 cookie
no_identity — 身份目录不存在
error — 读取异常
"""
import json
import logging
import os
import sqlite3
import time
from pathlib import Path
logger = logging.getLogger("guardd.account_monitor")
# ── 路径 ──────────────────────────────────────────────
AGENT_LOCAL = Path(os.environ.get(
"AGENT_LOCAL",
str(Path.home() / "workbuddy-agent-os" / "agent-local")
))
MATRIX_IDENTITIES = AGENT_LOCAL / "tools" / "matrix" / "identities"
OVERRIDE_PATH = AGENT_LOCAL / "tools" / "matrix" / "config" / "accounts.override.yaml"
class AccountMonitor:
"""本机账号登录状态实时采集器(60 秒缓存)"""
def __init__(self):
self._identities_root = MATRIX_IDENTITIES
self._override_path = OVERRIDE_PATH
self._cache = {} # {account_id: status}
self._cache_time = 0 # 上次缓存时间
self._cache_ttl = 60 # 秒
def collect_status(self) -> dict:
"""采集本机所有账号的登录状态(带 60 秒缓存)"""
import time as _t
now = _t.time()
if self._cache and now - self._cache_time < self._cache_ttl:
return dict(self._cache)
accounts = self._get_local_accounts()
result = {}
for account_id, identity_hint in accounts.items():
status = self._check_login_status(identity_hint)
result[account_id] = status
self._cache = result
self._cache_time = now
return result
def get_cached_status(self, account_id: str) -> str:
"""快速查某个账号的登录状态(只走缓存,不读盘)"""
if self._cache and account_id in self._cache:
return self._cache[account_id]
return "unknown"
def _get_local_accounts(self) -> dict:
"""获取本机账号列表 {account_id: identity_hint}"""
# 优先级 1: override.yaml
if self._override_path.exists():
try:
import yaml
data = yaml.safe_load(self._override_path.read_text()) or {}
accts = {}
for a in data.get("accounts", []):
aid = a.get("id", "")
if not aid:
continue
hint = a.get("identity_dir") or a.get("identity_hint") or aid
hint = hint.replace("identities/", "")
accts[aid] = hint
if accts:
return accts
except Exception as e:
logger.warning(f"读取 override.yaml 失败: {e}")
# 优先级 2: 扫描 identities/ 目录(降级)
if self._identities_root.exists():
accts = {}
for entry in sorted(self._identities_root.iterdir()):
if entry.is_dir() and not entry.name.startswith("."):
accts[entry.name] = entry.name
return accts
return {}
def _check_login_status(self, identity_hint: str) -> str:
"""检查单个身份目录的登录状态(逻辑同 matrix_mgmt._check_login_status_by_hint)"""
if not identity_hint:
return "no_identity"
identity_path = self._identities_root / identity_hint
if not identity_path.exists():
return "no_identity"
cookie_path = identity_path / "user_data" / "cookies.sqlite"
if not cookie_path.exists():
return "no_cookie"
if cookie_path.stat().st_size < 100:
return "empty_cookie"
try:
conn = sqlite3.connect(str(cookie_path), timeout=2)
cur = conn.cursor()
total = cur.execute("SELECT count(*) FROM moz_cookies").fetchone()[0]
session = cur.execute(
"SELECT count(*) FROM moz_cookies WHERE name LIKE '%session%'"
).fetchone()[0]
conn.close()
return "logged_in" if session > 0 else "no_session"
except Exception as e:
logger.debug(f"cookie 检查异常 {identity_hint}: {e}")
return "error"
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
executor.py — 任务执行器 (guardd 模块 同步版)
职责:
- 调 mc run / mc interact 等命令
- 实时解析 stdout 提取进度
- 超时控制(含 60 秒无输出检测)
- 执行结果写入 task_store
- shell=False(不再通过 /bin/sh 启动,避免僵尸 shell PID)
"""
import os
import re
import shlex
import signal
import subprocess
import time
from pathlib import Path
from typing import Optional
import logging
from modules.task_store import TaskStore, STATUS_RUNNING, STATUS_COMPLETED, STATUS_FAILED
logger = logging.getLogger("guardd.executor")
HOME = Path.home()
SCRIPTS_DIR = HOME / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "07_matrix" / "scripts"
PYTHON_PATH = HOME / ".workbuddy" / "binaries" / "python" / "envs" / "agent-os" / "bin" / "python3"
class Executor:
"""任务执行器(同步版本,shell=False)"""
def __init__(self, task_store: TaskStore, slot_manager=None):
self.task_store = task_store
self.slot_manager = slot_manager
self._running_procs = {} # task_id → proc
def execute(self, task: dict) -> dict:
"""执行一个任务,实时解析输出,返回结果"""
task_id = task["task_id"]
account_id = task.get("accounts", [""])[0] if task.get("accounts") else ""
identity_dir = task.get("identity_dir", account_id)
cmd_info = self._build_cmd(task)
if not cmd_info:
task["status"] = STATUS_FAILED
task["error"] = "无法构建执行命令"
self.task_store.save(task)
return {"success": False, "error": "无法构建执行命令"}
logger.info(f" ▶️ executor: task_id={task.get('task_id','')} accounts={task.get('accounts',[])} cmd_type={task.get('cmd_type','')}")
logger.info(f" ▶️ cmd: {' '.join(cmd_info['args'])[:200] if cmd_info.get('args') else 'NONE'}")
task["status"] = STATUS_RUNNING
task["started_at"] = time.time()
self.task_store.save(task)
# 获取槽位
slot = None
if self.slot_manager:
try:
nickname = task.get("nickname", "")
platform = task.get("platform", "")
if not platform and account_id:
platform = "xiaohongshu" if account_id.startswith("xhs_") else "douyin"
if not nickname:
try:
import json
pf = HOME / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
if pf.exists():
profiles = json.loads(pf.read_text())
if account_id in profiles:
nickname = profiles[account_id].get("nickname", "")
except Exception:
pass
slot = self.slot_manager.acquire(account_id, identity_dir, nickname, platform)
except Exception as e:
task["status"] = STATUS_FAILED
task["error"] = str(e)
self.task_store.save(task)
return {"success": False, "error": str(e)}
log_lines = []
# 任务日志文件
log_path = HOME / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "tasks" / f"{task_id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(log_path, "w", encoding="utf-8")
try:
# shell=False:直接启动 python3 -m ...,不经过 /bin/sh
proc = subprocess.Popen(
cmd_info["args"],
cwd=cmd_info.get("cwd"),
env=cmd_info.get("env"),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
preexec_fn=os.setsid
)
if slot:
self.slot_manager.set_pid(slot["browser_id"], proc.pid)
self._running_procs[task_id] = proc
max_execution_sec = task.get("max_execution_sec", 7200)
cmd_type = task.get("cmd_type", "")
# 仅登录任务给 5 分钟静默等待(首次登录需手动操作),其余保持 60 秒
idle_timeout = 300 if cmd_type in ("login", "smart-login") else 60
start_time = time.time()
last_output_time = time.time()
import select as _select
# 实时读取输出(非阻塞版)
while True:
now = time.time()
elapsed = now - start_time
# 浏览器启动超时:登录任务 5 分钟无输出,其他任务 60 秒无输出
if elapsed > idle_timeout and (now - last_output_time) > idle_timeout:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except Exception:
proc.kill()
task["status"] = STATUS_FAILED
task["error"] = "浏览器启动超时(60秒无输出)"
task["completed_at"] = time.time()
self.task_store.save(task)
logger.warning(f" ⏰ [{task_id}] 浏览器启动超时(60秒无输出)")
return {"success": False, "error": "浏览器启动超时"}
if elapsed > max_execution_sec:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except Exception:
proc.kill()
task["status"] = STATUS_FAILED
task["error"] = f"超时 ({max_execution_sec:.0f}s)"
task["completed_at"] = time.time()
self.task_store.save(task)
logger.warning(f" ⏰ [{task_id}] 超时 ({elapsed:.0f}s > {max_execution_sec}s)")
return {"success": False, "error": task["error"]}
r, _, _ = _select.select([proc.stdout], [], [], 2.0)
if r:
line = proc.stdout.readline()
if not line:
if proc.poll() is not None:
break
continue
text = line.decode("utf-8", errors="replace").strip()
log_lines.append(text)
log_fh.write(text + "\n")
log_fh.flush()
last_output_time = time.time()
self._parse_and_update(task_id, account_id, identity_dir, text)
else:
if proc.poll() is not None:
break
exit_code = proc.wait()
if exit_code != 0 and task.get("cmd_type") in ("collect", "nurture"):
try:
import json
pf = HOME / "workbuddy-agent-os" / "agent-local" / "tools" / "matrix" / "data" / "profiles.json"
if pf.exists():
mtime = pf.stat().st_mtime
started = task.get("started_at", 0)
if mtime >= started - 5:
logger.info(f" ✅ [{task_id}] profiles.json 已更新 (mtime={mtime:.0f} >= start={started:.0f})")
exit_code = 0
except Exception:
pass
success = exit_code == 0
task["status"] = STATUS_COMPLETED if success else STATUS_FAILED
task["completed_at"] = time.time()
task["exit_code"] = exit_code
self.task_store.save(task)
return {"success": success, "exit_code": exit_code, "task_id": task_id}
except Exception as e:
task["status"] = STATUS_FAILED
task["error"] = str(e)
task["completed_at"] = time.time()
self.task_store.save(task)
return {"success": False, "error": str(e)}
finally:
if slot:
self.slot_manager.release(slot["browser_id"])
self._running_procs.pop(task_id, None)
try:
log_fh.close()
except Exception:
pass
def _build_cmd(self, task: dict) -> Optional[dict]:
"""构建执行命令(返回 args/cwd/env 字典,shell=False)"""
scripts_dir = str(SCRIPTS_DIR)
python_path = str(PYTHON_PATH)
env = os.environ.copy()
env["PYTHONPATH"] = f"{scripts_dir}:{env.get('PYTHONPATH', '')}"
cmd_line = task.get("command_line", "")
if cmd_line:
# command_line 形如: mc task comment --account=X --url=Y -y
# 也有旧格式: mc run --accounts=X --blueprints=Y ...
args = [python_path, "-m"] + shlex.split(cmd_line)
return {"args": args, "cwd": scripts_dir, "env": env}
cmd_type = task.get("cmd_type", "")
accounts = ",".join(task.get("accounts", []))
blueprint = task.get("blueprint", "")
rounds = task.get("rounds", 1)
if cmd_type == "nurture":
args = [python_path, "-m", "mc", "run",
f"--accounts={accounts}", f"--blueprints={blueprint}",
f"--rounds={rounds}", "--mix", "--interval=45-90"]
return {"args": args, "cwd": scripts_dir, "env": env}
elif cmd_type in ("interact", "comment"):
url = task.get("params", {}).get("url", "")
direction = task.get("params", {}).get("direction", "")
args = [python_path, "-m", "mc", "run",
f"--accounts={accounts}", f"--blueprints={blueprint}",
"--rounds=1", f"--url={url}", f"--direction={direction}"]
interval = task.get("interval", 0)
if interval:
args.append(f"--interval={interval}")
return {"args": args, "cwd": scripts_dir, "env": env}
elif cmd_type == "collect":
args = [python_path, "-m", "mc", "run",
f"--accounts={accounts}", f"--blueprints={blueprint}", "--rounds=1"]
return {"args": args, "cwd": scripts_dir, "env": env}
elif cmd_type == "login":
args = [python_path, "-m", "mc", "smart-login", accounts]
return {"args": args, "cwd": scripts_dir, "env": env}
elif cmd_type == "record":
platform = task.get("params", {}).get("platform", "douyin")
args = [python_path, "-m", "mc", "record", "start",
f"--accounts={accounts}", f"--platform={platform}"]
return {"args": args, "cwd": scripts_dir, "env": env}
return None
def _parse_and_update(self, task_id: str, account_id: str, browser_id: str, line: str):
"""解析一行日志,更新任务/槽位进度"""
if not self.slot_manager:
return
# 更新 step
for pattern, step_name in [
(r"(?:正在|开始|执行)\s*([\u4e00-\u9fa5_]+)", None),
(r"step:\s*(\S+)", None),
]:
m = re.search(pattern, line)
if m:
self.slot_manager.update_step(browser_id, m.group(1))
break
# 更新 step_index(从 blueprint progress 提取)
m = re.search(r"(?:步骤|step)\s*(\d+)\s*[/|]\s*(\d+)", line)
if m:
self.slot_manager.update_step(browser_id, line[:40],
step_index=int(m.group(1)), total_steps=int(m.group(2)))
def kill(self, task_id: str) -> bool:
proc = self._running_procs.get(task_id)
if proc:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except Exception:
proc.kill()
return True
return False
@@ -0,0 +1,169 @@
"""
heartbeat.py — 增强心跳上报 (guardd 模块)
职责:
- 每15秒采集系统状态 + 任务状态 + 槽位状态
- 上报到 Dashboard (POST /api/push/heartbeat)
- 本地写入 status/{hostname}/heartbeat.json
"""
import json
import logging
import os
import subprocess
import time
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from modules.task_store import TaskStore
from modules.slot_manager import BrowserSlotManager
from modules.scheduler import Scheduler
from modules.account_monitor import AccountMonitor
logger = logging.getLogger("guardd.heartbeat")
class HeartbeatReporter:
"""增强心跳上报器"""
def __init__(self, task_store: TaskStore, slot_manager: BrowserSlotManager,
scheduler: Scheduler, hostname: str, machine_uid: str,
dashboard_url: str = None,
account_monitor: AccountMonitor = None):
self.task_store = task_store
self.slot_manager = slot_manager
self.scheduler = scheduler
self.hostname = hostname
self.machine_uid = machine_uid
self.dashboard_url = dashboard_url or "http://127.0.0.1:9988"
self.account_monitor = account_monitor
def collect(self) -> dict:
"""采集完整状态(v2: 含 per-slot 详细进度)"""
slot_usage = self.slot_manager.get_usage() if self.slot_manager else {"max": 0, "used": 0, "slots": []}
# 收集任务状态(v2: active_tasks 字典,支持多 slot 并行)
active_tasks = list(self.scheduler.active_tasks.values()) if hasattr(self.scheduler, 'active_tasks') and self.scheduler.active_tasks else []
active_list = []
for t in active_tasks:
at = self._task_to_heartbeat(t)
if at:
active_list.append(at)
queued = self.scheduler.get_all_queued() if hasattr(self.scheduler, 'get_all_queued') else []
task_counts = self.task_store.count()
# 增强 slot 信息:从 slot_manager 获取每个浏览器的当前步骤/时间
slot_details = []
for s in slot_usage.get("slots", []):
if s.get("account_id"):
slot_details.append({
"slot_id": s.get("slot_id"),
"account_id": s.get("account_id"),
"nickname": s.get("nickname", ""),
"platform": s.get("platform", ""),
"current_step": s.get("current_step", ""),
"step_index": s.get("step_index", 0),
"total_steps": s.get("total_steps", 0),
"elapsed_sec": s.get("elapsed_sec", 0),
"health": s.get("health", "healthy"),
"pid": s.get("pid"),
})
# 系统状态
cpu_load = -1
try:
cpu_load = round(os.getloadavg()[0], 2)
except OSError:
pass
# 采集每个账号的登录状态
account_status = {}
if self.account_monitor:
try:
account_status = self.account_monitor.collect_status()
except Exception as e:
logger.debug(f"账号状态采集失败: {e}")
heartbeat = {
"hostname": self.hostname,
"machine_uid": self.machine_uid,
"status": "online",
"last_seen": datetime.now(timezone.utc).isoformat(),
"version": "4.3.0",
"account_status": account_status,
"slots": {
"max": slot_usage.get("max", 3),
"used": slot_usage.get("used", 0),
"list": slot_details,
},
"tasks": {
"active": active_list,
"queued": [
{"task_id": q["task_id"], "priority": q.get("priority", 1),
"estimated_at": q.get("scheduled_at", 0)}
for q in queued[:20]
],
"counts": task_counts,
},
"system": {
"cpu_load_1m": cpu_load,
"browsers_open": slot_usage.get("used", 0),
},
}
return heartbeat
def _task_to_heartbeat(self, task: dict) -> dict:
"""把 task dict 转成心跳用的紧凑格式"""
if not task:
return None
return {
"task_id": task.get("task_id", ""),
"cmd_type": task.get("cmd_type", ""),
"type": task.get("task_type", task.get("cmd_type", "")),
"account": (task.get("accounts") or [""])[0],
"blueprint": task.get("blueprint", ""),
"status": task.get("status", ""),
"started_at": task.get("started_at", 0),
"elapsed_sec": int(time.time() - task.get("started_at", time.time())),
"progress": {
"current_step": task.get("current_step", ""),
"step_index": task.get("step_index", 0),
"total_steps": task.get("total_steps", 0),
},
}
def send_to_dashboard(self, heartbeat: dict = None):
"""发送心跳到 Dashboard"""
if heartbeat is None:
heartbeat = self.collect()
try:
payload = json.dumps(heartbeat, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
f"{self.dashboard_url}/api/push/heartbeat",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
except Exception as e:
logger.debug(f"心跳推送失败: {e}")
def write_local(self, heartbeat: dict = None):
"""写入本地 heartbeat.json"""
if heartbeat is None:
heartbeat = self.collect()
try:
home = Path.home()
status_dir = home / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / self.hostname
status_dir.mkdir(parents=True, exist_ok=True)
path = status_dir / "heartbeat.json"
path.write_text(json.dumps(heartbeat, indent=2, ensure_ascii=False), encoding="utf-8")
# also write live file
live_dir = status_dir.parent / "live"
live_dir.mkdir(parents=True, exist_ok=True)
(live_dir / f"{self.machine_uid}.json").write_text(
json.dumps(heartbeat, indent=2, ensure_ascii=False), encoding="utf-8"
)
except Exception as e:
logger.error(f"本地心跳写入失败: {e}")
@@ -0,0 +1,78 @@
"""
oracle_sync.py — ORACLE 定时任务同步 (guardd 模块)
职责:
- guardd 启动时读取 ORACLE.yaml 的 schedules 节
- 将定时任务导入到 task_store
- 每6小时增量同步
"""
import logging
import time
from pathlib import Path
from modules.task_store import TaskStore, STATUS_SCHEDULED
logger = logging.getLogger("guardd.oracle_sync")
SYNC_INTERVAL = 6 * 3600 # 6小时
class OracleSync:
"""ORACLE.yaml 定时任务同步器"""
def __init__(self, task_store: TaskStore):
self.task_store = task_store
self.last_sync = 0
self.home = Path.home()
self.oracle_path = self.home / "workbuddy-agent-os" / "agent-sync" / "ORACLE.yaml"
def sync(self):
"""执行同步(启动时 + 周期调用)"""
now = time.time()
if now - self.last_sync < SYNC_INTERVAL:
return
self.last_sync = now
if not self.oracle_path.exists():
logger.info(" ORACLE.yaml 不存在,跳过定时任务同步")
return
try:
import yaml
oracle = yaml.safe_load(self.oracle_path.read_text())
schedules = oracle.get("schedules", [])
count = 0
for s in schedules:
task_id = f"oracle_{s['name']}_{int(now)}"
# 检查是否已存在
existing = self.task_store.get(task_id)
if existing:
continue
blueprint = s.get("action", "").replace("mc run", "").strip()
if not blueprint:
blueprint = s.get("params", {}).get("blueprint", "douyin_daily")
task = {
"task_id": task_id,
"cmd_type": "nurture",
"source": "oracle",
"accounts": s.get("accounts", "all"),
"machine": s.get("on_machines", "*"),
"priority": 1,
"schedule_type": "cron",
"cron_expr": s["schedule"],
"blueprint": blueprint,
"rounds": s.get("params", {}).get("rounds", 10),
"status": STATUS_SCHEDULED,
"created_at": now,
}
self.task_store.save(task)
count += 1
logger.info(f" 📅 [{task_id}] 导入定时任务: {s['schedule']} {blueprint}")
if count:
logger.info(f" ✅ 导入 {count} 条定时任务")
else:
logger.debug(" 没有新的定时任务需要导入")
except Exception as e:
logger.error(f" ORACLE 同步异常: {e}")
@@ -0,0 +1,110 @@
"""
priority_queue.py — 优先级队列 (guardd 模块)
基于 heapq 实现,支持:
- 按优先级排序(0=最高)
- 按时间调度(scheduled_at)
- 取消/重排
"""
import heapq
import time
from typing import Optional, List
from threading import Lock
class PriorityQueue:
"""优先级队列 — 支持时间调度"""
def __init__(self):
self._heap = []
self._task_map = {} # task_id → (priority, scheduled_at, task_id)
self._lock = Lock()
def push(self, task: dict) -> None:
"""推入任务,保留 accounts/cmd_type 等关键字段"""
task_id = task["task_id"]
priority = task.get("priority", 1)
scheduled_at = task.get("scheduled_at", 0) or 0
if isinstance(scheduled_at, str):
try:
scheduled_at = float(scheduled_at)
except (ValueError, TypeError):
scheduled_at = 0
item = (priority, scheduled_at, task_id)
with self._lock:
heapq.heappush(self._heap, item)
self._task_map[task_id] = {
"priority": priority,
"scheduled_at": scheduled_at,
"accounts": task.get("accounts", []),
"cmd_type": task.get("cmd_type", ""),
"queued_at": task.get("queued_at", 0),
"command_line": task.get("command_line", ""),
"blueprint": task.get("blueprint", ""),
}
def pop_ready(self, now: float = None) -> Optional[str]:
"""取出到时间且优先级最高的任务,返回 task_id"""
if now is None:
now = time.time()
with self._lock:
while self._heap:
priority, scheduled_at, task_id = self._heap[0]
if scheduled_at <= now:
heapq.heappop(self._heap)
self._task_map.pop(task_id, None)
return task_id
else:
return None # 堆顶还没到时间
return None
def peek(self) -> Optional[dict]:
"""查看堆顶但不取出"""
with self._lock:
if not self._heap:
return None
priority, scheduled_at, task_id = self._heap[0]
return {"task_id": task_id, "priority": priority, "scheduled_at": scheduled_at}
def remove(self, task_id: str) -> bool:
"""从队列中移除任务"""
with self._lock:
if task_id in self._task_map:
# 标记移除(实际在 pop 时清理)
self._task_map[task_id] = None
return True
return False
def is_empty(self) -> bool:
with self._lock:
return len(self._heap) == 0
def size(self) -> int:
with self._lock:
return len(self._heap)
def clear(self):
"""清空队列"""
with self._lock:
self._heap.clear()
self._task_map.clear()
def get_all(self) -> List[dict]:
"""返回所有队列中的任务信息(含 accounts/cmd_type 等字段)"""
with self._lock:
result = []
for p, s, tid in self._heap:
info = self._task_map.get(tid)
if info is None:
continue
result.append({
"task_id": tid,
"priority": info.get("priority", p),
"scheduled_at": info.get("scheduled_at", s),
"accounts": info.get("accounts", []),
"cmd_type": info.get("cmd_type", ""),
"queued_at": info.get("queued_at", 0),
"command_line": info.get("command_line", ""),
"blueprint": info.get("blueprint", ""),
})
return result
@@ -0,0 +1,135 @@
"""
schedule_bridge.py — 定时任务桥接 (guardd 模块)
职责:
- 读取 mc/scheduler 的 schedule.yaml
- 检查到期的定时任务
- 提交到 guardd scheduler 执行(P1 优先级)
- 记录最后执行时间,防止重复触发
配置路径: 05_tools/07_matrix/scripts/config/schedule.yaml
"""
import json
import logging
import time
from datetime import datetime
from pathlib import Path
from modules.task_store import TaskStore
logger = logging.getLogger("guardd.schedule_bridge")
HOME = Path.home()
SCRIPTS_DIR = HOME / "workbuddy-agent-os" / "agent-sync" / "05_tools" / "07_matrix" / "scripts"
SCHEDULE_YAML = SCRIPTS_DIR / "config" / "schedule.yaml"
LAST_RUN_FILE = HOME / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "schedule_last_run.json"
CHECK_INTERVAL = 60 # 每 60 秒检查一次
class ScheduleBridge:
"""定时任务 → guardd 调度器桥接"""
def __init__(self, task_store: TaskStore, scheduler=None, hostname: str = ""):
self.task_store = task_store
self.scheduler = scheduler
self.hostname = hostname
self.last_check = 0
self._last_run_cache = {}
def load_last_runs(self):
"""加载上次执行时间记录"""
if LAST_RUN_FILE.exists():
try:
self._last_run_cache = json.loads(LAST_RUN_FILE.read_text())
except Exception:
self._last_run_cache = {}
def save_last_runs(self):
"""保存上次执行时间"""
LAST_RUN_FILE.parent.mkdir(parents=True, exist_ok=True)
LAST_RUN_FILE.write_text(json.dumps(self._last_run_cache, indent=2))
def check_schedules(self):
"""检查是否有定时任务到期"""
now = time.time()
if now - self.last_check < CHECK_INTERVAL:
return 0
self.last_check = now
if not SCHEDULE_YAML.exists():
return 0
try:
import yaml
data = yaml.safe_load(SCHEDULE_YAML.read_text())
schedules = data.get("schedules", {}) if data else {}
except Exception as e:
logger.warning(f"schedule.yaml 读取失败: {e}")
return 0
self.load_last_runs()
triggered = 0
now_dt = datetime.now()
today_minutes = now_dt.hour * 60 + now_dt.minute
weekday = now_dt.isoweekday() # 1=周一..7=周日
for sid, s in schedules.items():
if not s.get("enabled", False):
continue
# 检查星期匹配
days_str = s.get("days", "1,2,3,4,5,6,7")
days = [int(d.strip()) for d in days_str.split(",") if d.strip()]
if weekday not in days:
continue
# 检查时间匹配
time_str = s.get("time", "")
if not time_str:
continue
try:
h, m = time_str.split(":")
schedule_minutes = int(h) * 60 + int(m)
except (ValueError, TypeError):
continue
# 允许 1 分钟误差(防止跳过)
if abs(today_minutes - schedule_minutes) > 1:
continue
# 检查是否已触发过(防止重复)
last_run = self._last_run_cache.get(sid, 0)
if now - last_run < CHECK_INTERVAL * 2:
continue
# 提交到 guardd scheduler
account = s.get("account", "")
blueprint = s.get("blueprint", "douyin_daily")
rounds = s.get("rounds", 3)
task_id = f"schedule_{sid}_{int(now)}"
task = {
"task_id": task_id,
"cmd_type": "nurture",
"accounts": [account] if account else [],
"blueprint": blueprint,
"rounds": rounds,
"priority": 1, # P1 日常
"source": "schedule",
"schedule_id": sid,
"params": {},
}
if self.scheduler and hasattr(self.scheduler, 'submit_task'):
self.scheduler.submit_task(task)
logger.info(f" 📅 [{task_id}] 定时任务触发: {sid} → {blueprint} @ {account}")
else:
logger.warning(f" ⚠️ scheduler 不可用,定时任务 {sid} 无法提交")
self._last_run_cache[sid] = now
triggered += 1
if triggered:
self.save_last_runs()
return triggered
@@ -0,0 +1,350 @@
"""
scheduler.py — 调度引擎 v4(简化版)
核心变化(vs v3):
1. 一条队列全局排序,3 slot 从队首取任务
2. P0 插入规则: 找第一个"后面没有 P0"的 P1 → 插它后面(自动交替)
3. 去掉 interval、decomposed_from、paused_slots 全部复杂逻辑
4. executor 读输出改为非阻塞(select)
5. _pop_next 增加 task_store 状态校验,过期自动清除
"""
import logging
import threading
from threading import RLock
import time
from typing import Optional
from modules.task_store import (
TaskStore, STATUS_QUEUED, STATUS_RUNNING,
STATUS_COMPLETED, STATUS_FAILED, TERMINAL_STATUSES
)
from modules.priority_queue import PriorityQueue
from modules.slot_manager import BrowserSlotManager
from modules.executor import Executor
logger = logging.getLogger("guardd.scheduler")
class Scheduler:
"""调度引擎 v4 — 单队列 + 交替规则 + 事件驱动编排"""
def __init__(self, task_store: TaskStore, priority_queue: PriorityQueue,
slot_manager: BrowserSlotManager, executor: Executor,
on_task_event=None):
self.task_store = task_store
self.queue_priority = PriorityQueue() # P0
self.queue_normal = PriorityQueue() # P1
self.queue_filler = PriorityQueue() # P2
self.slot_manager = slot_manager
self.executor = executor
self.active_tasks: dict[int, dict] = {}
self.account_slots: dict[str, int] = {}
self.on_task_event = on_task_event
self.loop_interval = 15
self.max_slots = slot_manager.max_slots if slot_manager else 3
self._schedule_lock = RLock()
# ═══════════════════════════════════════════════════════
# 主循环
# ═══════════════════════════════════════════════════════
def run_cycle(self):
while True:
try:
self._check_all_active_tasks()
self._schedule_all_slots()
self.slot_manager.check_health()
except Exception as e:
logger.error(f"调度循环异常: {e}")
time.sleep(self.loop_interval)
# ═══════════════════════════════════════════════════════
# 任务提交
# ═══════════════════════════════════════════════════════
def submit_task(self, task: dict):
with self._schedule_lock:
self._submit_task_inner(task)
def _submit_task_inner(self, task: dict):
task_id = task["task_id"]
task["status"] = STATUS_QUEUED
task["queued_at"] = time.time()
self.task_store.save(task)
priority = task.get("priority", 1)
if priority == 0:
self._insert_p0(task)
elif priority == 2:
self.queue_filler.push(task)
logger.info(f" 📥 [{task_id[:30]}] 入队 P2 (队尾)")
else:
self.queue_normal.push(task)
logger.info(f" 📥 [{task_id[:30]}] 入队 P1 (日常)")
if self._has_free_slot():
self._schedule_all_slots()
def _insert_p0(self, task: dict):
"""P0 插入: 直接入 priority 队列"""
task_id = task["task_id"]
self.queue_priority.push(task)
logger.info(f" 📥 [{task_id[:30]}] 入队 P0")
def _has_free_slot(self) -> bool:
"""检查是否有空闲 slot。使用 active_tasks 长度作为主要依据,
避免 executor 线程尚未调用 acquire() 时的竞态条件。"""
# active_tasks 是同步设置的(在 _assign_task 中),比 slot_manager 更准确
active_count = len(self.active_tasks)
max_s = self.max_slots
free = active_count < max_s
return free
# ═══════════════════════════════════════════════════════
# 活跃任务检查
# ═══════════════════════════════════════════════════════
def _check_all_active_tasks(self):
if not self.active_tasks:
return
for slot_id, task in list(self.active_tasks.items()):
status = task.get("status", "")
if status not in TERMINAL_STATUSES:
continue
logger.info(f" ✅ [{task['task_id'][:30]}] 完成 (status={status})")
if self.on_task_event:
try:
self.on_task_event("completed" if status == STATUS_COMPLETED else "failed", task)
except Exception:
pass
self._release_slot(slot_id)
def _release_slot(self, slot_id: int):
task = self.active_tasks.pop(slot_id, None)
if task:
for acct in (task.get("accounts") or []):
if acct and acct in self.account_slots:
del self.account_slots[acct]
logger.info(f" 🔓 slot {slot_id} 释放账号 {acct}")
# 同步释放 slot_manager
if self.slot_manager:
try:
self.slot_manager.release_by_account(acct)
except Exception:
pass
# ═══════════════════════════════════════════════════════
# Slot 分配
# ═══════════════════════════════════════════════════════
def _schedule_all_slots(self):
with self._schedule_lock:
self._schedule_all_inner()
def _schedule_all_inner(self):
usage = self.slot_manager.get_usage() if self.slot_manager else {"max": self.max_slots, "used": 0, "slots": []}
max_s = usage.get("max", self.max_slots)
for slot_id in range(max_s):
if slot_id in self.active_tasks:
continue
if self.slot_manager:
slots_info = usage.get("slots", [])
slot_info = next((s for s in slots_info if s.get("slot_id") == slot_id), None)
if slot_info and slot_info.get("account_id"):
continue
task = self._pop_next(slot_id)
if not task:
continue
self._assign_task(slot_id, task)
def _pop_next(self, slot_id: int) -> Optional[dict]:
"""取下一个可用任务,按 P0/P1 交替规则,跳过 busy 账号和过期条目。
交替规则: 优先取 P0,但已分配的 P0 数量>=P1 时取 P1,
确保 P0 和 P1 交替执行,不会 3 个 slot 全跑 P0。
"""
# 统计本轮已分配给前几个 slot 的 P0/P1 数量
p0_assigned = 0
p1_assigned = 0
for sid in range(slot_id):
_t = self.active_tasks.get(sid)
if _t:
_p = _t.get("priority")
if _p == 0:
p0_assigned += 1
elif _p == 1:
p1_assigned += 1
# 交替策略:P0 >= P1 时优先取 P1(P0 永远跟在 P1 后面)
prefer_p1 = (p0_assigned >= p1_assigned) and self.queue_normal.size() > 0
if prefer_p1:
queue_order = [
(self.queue_normal, "P1"),
(self.queue_priority, "P0"),
(self.queue_filler, "P2"),
]
else:
queue_order = [
(self.queue_priority, "P0"),
(self.queue_normal, "P1"),
(self.queue_filler, "P2"),
]
for queue, _ in queue_order:
candidates = queue.get_all()
candidates.sort(key=lambda c: c.get("queued_at", 0))
for candidate in candidates:
accounts = candidate.get("accounts", [])
if any(acct in self.account_slots for acct in accounts):
continue
task_id = candidate["task_id"]
# 校验 task_store 状态
stored = self.task_store.get(task_id)
if not stored:
queue.remove(task_id)
logger.warning(f" ⏭ [{task_id[:30]}] 过期清除(无记录)")
continue
if stored.get("status") != STATUS_QUEUED:
queue.remove(task_id)
logger.info(f" ⏭ [{task_id[:30]}] 过期清除(status={stored.get('status')})")
continue
removed = queue.remove(task_id)
if not removed:
continue
candidate["status"] = STATUS_RUNNING
self.task_store.save(candidate)
return candidate
return None
def _assign_task(self, slot_id: int, task: dict):
self.active_tasks[slot_id] = task
task["started_at"] = time.time()
task["slot_id"] = slot_id
for acct in (task.get("accounts") or []):
if acct:
self.account_slots[acct] = slot_id
self.task_store.save(task)
accts_str = ",".join(task.get("accounts", []))
logger.info(f" ▶️ [{task['task_id'][:30]}] slot {slot_id} 开始 (accounts={accts_str})")
if self.on_task_event:
try:
self.on_task_event("started", task)
except Exception:
pass
try:
import threading
threading.Thread(target=self.executor.execute, args=(task,), daemon=True).start()
except Exception as e:
logger.error(f" ❌ [{task['task_id'][:30]}] 执行失败: {e}")
# ═══════════════════════════════════════════════════════
# 辅助
# ═══════════════════════════════════════════════════════
def kill_active(self):
for slot_id, task in list(self.active_tasks.items()):
try:
self.executor.kill(task["task_id"])
except Exception:
pass
self.active_tasks.clear()
self.account_slots.clear()
def clear_all(self):
"""清空所有队列 + 杀死活跃任务,完全还原"""
self.kill_active()
# 清空内存队列
for q in [self.queue_priority, self.queue_normal, self.queue_filler]:
q.clear()
# 将 task_store 中所有 queued/pending 状态的任务标记为 cancelled
for status in ("queued", "pending"):
for t in self.task_store.get_by_status(status):
t["status"] = "cancelled"
self.task_store.save(t)
if self.slot_manager:
self.slot_manager.cleanup_orphans()
def queue_sizes(self) -> dict:
return {
"priority": len(self.queue_priority.get_all()),
"normal": len(self.queue_normal.get_all()),
"filler": len(self.queue_filler.get_all()),
}
def get_all_queued(self) -> list:
"""返回排队队列(P0/P1 交替排序,P2 附加末尾)"""
p0_list = self.queue_priority.get_all()
p1_list = self.queue_normal.get_all()
p2_list = self.queue_filler.get_all()
# 按 queued_at 排序后交替合并
p0_list.sort(key=lambda x: x.get("queued_at", 0))
p1_list.sort(key=lambda x: x.get("queued_at", 0))
result = []
p0_i, p1_i, p2_i = 0, 0, 0
last_p0 = True # 从 P1 开始交替(P0 永远跟在 P1 后面)
while p0_i < len(p0_list) or p1_i < len(p1_list):
if p0_i >= len(p0_list):
# 只剩 P1
p1_list[p1_i]["queue"] = "P1"
result.append(p1_list[p1_i])
p1_i += 1
last_p0 = False
elif p1_i >= len(p1_list):
# 只剩 P0 → 用 P2 作间隔
if last_p0 and p2_i < len(p2_list):
p2_list[p2_i]["queue"] = "P2"
result.append(p2_list[p2_i])
p2_i += 1
last_p0 = False
else:
p0_list[p0_i]["queue"] = "P0"
result.append(p0_list[p0_i])
p0_i += 1
last_p0 = True
# P0 后面没 P1 → 补一个 P2 作间隔
if p2_i < len(p2_list):
p2_list[p2_i]["queue"] = "P2"
result.append(p2_list[p2_i])
p2_i += 1
last_p0 = False
elif last_p0:
# 上次取了 P0,这次取 P1(交替)
p1_list[p1_i]["queue"] = "P1"
result.append(p1_list[p1_i])
p1_i += 1
last_p0 = False
else:
# 上次取了 P1 或刚开始,取 P0
p0_list[p0_i]["queue"] = "P0"
result.append(p0_list[p0_i])
p0_i += 1
last_p0 = True
# 剩余的 P2 附加末尾
for i in range(p2_i, len(p2_list)):
p2_list[i]["queue"] = "P2"
result.append(p2_list[i])
return result
@@ -0,0 +1,238 @@
"""
slot_manager.py — 浏览器槽位管理器 (guardd 模块)
职责:
- 管理本机 Camoufox 浏览器实例(槽位)
- 按 browser_id 识别,不依赖槽位编号(用户拖拽窗口不影响)
- 同一账号不能同时在两个浏览器运行
- 启动时扫描孤儿浏览器进程
"""
import os
import signal
import subprocess
import time
from threading import Lock
from typing import Optional, List
class AccountBusyError(Exception):
"""账号已在其他槽位运行"""
pass
class BrowserSlotManager:
"""浏览器槽位管理器"""
def __init__(self, max_slots: int = 3):
self.max_slots = max_slots
self.slots = [None] * max_slots # list of dict or None
self._lock = Lock()
def acquire(self, account_id: str, identity_dir: str, nickname: str = "",
platform: str = "douyin") -> Optional[dict]:
"""获取一个槽位,返回 slot_info dict
Args:
account_id: 账号ID (douyin_136)
identity_dir: 身份目录名
nickname: 账号昵称 (显示用)
platform: 平台 (douyin / xiaohongshu)
Raises: AccountBusyError 如果账号已在其他槽位运行
"""
with self._lock:
for s in self.slots:
if s and s["account_id"] == account_id:
raise AccountBusyError(f"账号 {account_id} 已在 slot {s['slot_id']} 运行")
for i in range(self.max_slots):
if self.slots[i] is None:
info = {
"slot_id": i,
"account_id": account_id,
"nickname": nickname,
"platform": platform,
"browser_id": identity_dir,
"pid": None,
"blueprint": None,
"current_step": None,
"step_index": 0,
"total_steps": 0,
"started_at": time.time(),
"elapsed_sec": 0,
"cpu_percent": 0.0,
"memory_mb": 0.0,
"health": "healthy",
"last_heartbeat": time.time(),
}
self.slots[i] = info
return info
return None
def release(self, browser_id: str) -> bool:
"""按 browser_id 释放槽位(不依赖槽位编号)"""
with self._lock:
for i in range(self.max_slots):
if self.slots[i] and self.slots[i]["browser_id"] == browser_id:
pid = self.slots[i].get("pid")
if pid:
try:
os.kill(pid, signal.SIGTERM)
except (OSError, ProcessLookupError):
pass
self.slots[i] = None
return True
return False
def release_by_account(self, account_id: str) -> bool:
"""按 account_id 释放槽位"""
with self._lock:
for i in range(self.max_slots):
if self.slots[i] and self.slots[i]["account_id"] == account_id:
self.slots[i] = None
return True
return False
def update_step(self, browser_id: str, step_name: str, step_index: int = None, total_steps: int = None):
"""更新槽位中正在执行的步骤"""
with self._lock:
for s in self.slots:
if s and s["browser_id"] == browser_id:
s["current_step"] = step_name
s["elapsed_sec"] = int(time.time() - s["started_at"])
s["last_heartbeat"] = time.time()
if step_index is not None:
s["step_index"] = step_index
if total_steps is not None:
s["total_steps"] = total_steps
return True
return False
def set_pid(self, browser_id: str, pid: int):
"""设置浏览器进程 PID"""
with self._lock:
for s in self.slots:
if s and s["browser_id"] == browser_id:
s["pid"] = pid
return True
return False
def find_account(self, account_id: str) -> Optional[dict]:
"""查找账号在当前哪个槽位运行"""
with self._lock:
for s in self.slots:
if s and s["account_id"] == account_id:
return s
return None
def find_by_browser(self, browser_id: str) -> Optional[dict]:
with self._lock:
for s in self.slots:
if s and s["browser_id"] == browser_id:
return s
return None
def check_health(self):
"""每轮 cycle 检查所有浏览器进程健康状态"""
with self._lock:
for s in self.slots:
if not s or not s.get("pid"):
continue
try:
os.kill(s["pid"], 0) # 空信号测存活
except OSError:
s["health"] = "crashed"
s["account_id"] = None
continue
# 采集 CPU/内存
try:
r = subprocess.run(
["ps", "-p", str(s["pid"]), "-o", "%cpu=,%mem=,rss="],
capture_output=True, text=True, timeout=3
)
parts = r.stdout.strip().split()
if len(parts) >= 3:
s["cpu_percent"] = float(parts[0])
s["memory_mb"] = float(parts[2]) / 1024
s["health"] = "warning" if float(parts[0]) > 80 else "healthy"
except Exception:
pass
s["elapsed_sec"] = int(time.time() - s["started_at"])
s["last_heartbeat"] = time.time()
def cleanup_orphans(self):
"""启动时扫描孤儿浏览器进程"""
print(" [SlotManager] 扫描孤儿浏览器进程...")
try:
r = subprocess.run(
["ps", "aux"],
capture_output=True, text=True, timeout=5
)
orphan_count = 0
for line in r.stdout.split("\n"):
if "camoufox" in line.lower() or "firefox" in line.lower():
parts = line.split()
if len(parts) > 1:
pid = parts[1]
try:
os.kill(int(pid), signal.SIGTERM)
orphan_count += 1
except (OSError, ValueError):
pass
if orphan_count:
print(f" [SlotManager] 清理 {orphan_count} 个孤儿浏览器进程")
except Exception as e:
print(f" [SlotManager] 清理异常: {e}")
def check_processes(self):
"""逐 slot 检查 PID 存活状态(5 秒线程调用)"""
with self._lock:
for s in self.slots:
if not s or not s.get("pid"):
continue
pid = s["pid"]
alive = True
try:
os.kill(pid, 0)
except OSError:
alive = False
if alive:
s["health"] = "healthy"
s["last_heartbeat"] = time.time()
else:
s["health"] = "crashed"
s["pid"] = None
s["account_id"] = None
def track_loop(self, interval: float = 5.0):
"""后台线程:每 5 秒检查一次所有 slot 的进程存活"""
logger = __import__("logging").getLogger("guardd.slot_manager")
while True:
try:
self.check_processes()
except Exception as e:
logger.error(f"track_loop 异常: {e}")
__import__("time").sleep(interval)
def get_usage(self) -> dict:
with self._lock:
slots_info = []
for i, s in enumerate(self.slots):
if s:
slots_info.append({
"slot_id": i,
"account_id": s["account_id"],
"nickname": s.get("nickname", ""),
"platform": s.get("platform", ""),
"browser_id": s["browser_id"],
"pid": s["pid"],
"blueprint": s.get("blueprint"),
"current_step": s.get("current_step"),
"step_index": s.get("step_index", 0),
"total_steps": s.get("total_steps", 0),
"elapsed_sec": s.get("elapsed_sec", 0),
"health": s.get("health", "unknown"),
})
return {
"max": self.max_slots,
"used": sum(1 for s in self.slots if s is not None),
"slots": slots_info,
}
@@ -0,0 +1,176 @@
"""
task_store.py — 任务持久化存储 (guardd 模块)
职责:
- 内存 dict + SQLite 双写
- 内存保性能,SQLite 持久化
- 启动时从未完成的任务恢复
"""
import json
import sqlite3
import threading
import time
from pathlib import Path
from typing import Optional, List
from datetime import datetime
# Task status constants
STATUS_PENDING = "pending"
STATUS_SCHEDULED = "scheduled"
STATUS_QUEUED = "queued"
STATUS_PREFLIGHT = "preflight"
STATUS_RUNNING = "running"
STATUS_PAUSED = "paused"
STATUS_WAITING_DEP = "waiting_dep"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
STATUS_CANCELLED = "cancelled"
STATUS_SKIPPED = "skipped"
TERMINAL_STATUSES = {STATUS_COMPLETED, STATUS_FAILED, STATUS_CANCELLED, STATUS_SKIPPED}
ACTIVE_STATUSES = {STATUS_RUNNING, STATUS_QUEUED, STATUS_PREFLIGHT, STATUS_PAUSED, STATUS_WAITING_DEP}
class TaskStore:
"""任务持久化存储 — 内存 dict + SQLite 双写"""
def __init__(self, db_path: str = None):
if db_path is None:
home = Path.home()
db_path = str(home / "workbuddy-agent-os" / "agent-local" / "runtime" / "guardd" / "tasks.db")
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self.db_path = db_path
self._lock = threading.Lock()
self._mem: dict = {} # task_id → task dict
self._init_db()
self._load_from_db()
def _init_db(self):
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS account_health (
account_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at REAL NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)
""")
conn.commit()
def _load_from_db(self):
"""启动时从 SQLite 加载所有任务到内存"""
with sqlite3.connect(self.db_path) as conn:
rows = conn.execute("SELECT task_id, data, status FROM tasks").fetchall()
for task_id, data_json, status in rows:
task = json.loads(data_json)
task["status"] = status
self._mem[task_id] = task
def save(self, task: dict) -> None:
"""保存/更新任务(内存 + SQLite)"""
task_id = task["task_id"]
status = task.get("status", STATUS_PENDING)
now = time.time()
with self._lock:
self._mem[task_id] = task
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"INSERT OR REPLACE INTO tasks (task_id, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
(task_id, json.dumps(task, ensure_ascii=False), status, task.get("created_at", now), now),
)
conn.commit()
def get(self, task_id: str) -> Optional[dict]:
with self._lock:
return self._mem.get(task_id)
def get_by_status(self, *statuses) -> List[dict]:
with self._lock:
return [t for t in self._mem.values() if t.get("status") in statuses]
def get_active(self) -> List[dict]:
return self.get_by_status(*ACTIVE_STATUSES)
def find_dependents(self, task_id: str) -> List[str]:
"""查找依赖指定 task_id 的所有任务"""
result = []
with self._lock:
for tid, task in self._mem.items():
deps = task.get("depends_on", [])
if task_id in deps:
result.append(tid)
return result
def save_account_health(self, account_id: str, data: dict) -> None:
data["updated_at"] = time.time()
with self._lock:
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"INSERT OR REPLACE INTO account_health (account_id, data, updated_at) VALUES (?, ?, ?)",
(account_id, json.dumps(data, ensure_ascii=False), time.time()),
)
conn.commit()
def get_account_health(self, account_id: str) -> Optional[dict]:
with sqlite3.connect(self.db_path) as conn:
row = conn.execute("SELECT data FROM account_health WHERE account_id = ?", (account_id,)).fetchone()
if row:
return json.loads(row[0])
return None
def get_unfinished(self) -> List[dict]:
"""获取所有未完成的任务(启动恢复用)"""
return self.get_by_status(*ACTIVE_STATUSES)
def reset_unfinished(self) -> int:
"""启动时将 running 状态重置为 queued"""
count = 0
for task in self.get_unfinished():
if task.get("status") == STATUS_RUNNING:
task["status"] = STATUS_QUEUED
task["message"] = "guardd 重启恢复"
self.save(task)
count += 1
return count
def get_group_last_completed(self, group_id: str) -> Optional[float]:
"""获取某组任务最后一次完成的时间戳
Args:
group_id: 任务组ID (decomposed_from 或 task_id)
Returns:
最后完成时间的 Unix 时间戳,没有完成过的返回 None
"""
with self._lock:
last_time = None
for tid, task in self._mem.items():
if task.get("decomposed_from") == group_id or tid == group_id:
last_completed = task.get("completed_at")
if last_completed and (last_time is None or last_completed > last_time):
last_time = last_completed
return last_time
def all_tasks(self) -> List[dict]:
with self._lock:
return list(self._mem.values())
def count(self) -> dict:
with self._lock:
counts = {}
for t in self._mem.values():
s = t.get("status", "unknown")
counts[s] = counts.get(s, 0) + 1
return counts
@@ -0,0 +1,113 @@
#!/bin/bash
# chrome_debug.sh — 启动 Chrome 远程调试(供 launchd 调用)
# 确保 Chrome 以 --remote-debugging-port=9222 运行
#
# v2 — 增强版
# - 所有日志带时间戳
# - 重启次数限制(最多 5 次,超出后退出等待手动处理)
# - 重启间隔递增(5s → 15s → 30s)
# - 检测到 OOM 时打印内存状态
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
USER_DATA_DIR="/tmp/chrome-douyin-profile"
PIDFILE="/tmp/chrome-debug.pid"
LOG_FILE="/tmp/chrome_debug_launchd.log"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a /tmp/chrome_debug_watch.log
}
is_9222_alive() {
curl -s --connect-timeout 3 http://127.0.0.1:9222/json/version >/dev/null 2>&1
}
launch_chrome() {
nohup "$CHROME" \
--remote-debugging-port=9222 \
--user-data-dir="$USER_DATA_DIR" \
--no-first-run --no-default-browser-check \
--disable-features=ChromeWhatsNewUI \
--window-position=9999,9999 \
about:blank \
> "$LOG_FILE" 2>&1 &
echo $! > "$PIDFILE"
log "Chrome 已启动 (PID $!),屏幕外位置 (9999,9999)"
}
# ── 主流程 ──
log "=== chrome_debug.sh 启动 ==="
# 先检查 9222 是否已经在线
if is_9222_alive; then
log "端口 9222 已有 Chrome 在监听,进入健康检查循环"
else
log "端口 9222 无响应,启动 Chrome..."
launch_chrome
# 等最多 15 秒
for i in $(seq 1 15); do
sleep 1
if is_9222_alive; then
log "Chrome debug 启动成功(第 ${i} 秒)"
break
fi
done
if ! is_9222_alive; then
log "❌ Chrome 启动超时(15 秒),请检查: cat $LOG_FILE"
exit 1
fi
fi
# ── 健康检查循环 ──
RESTART_COUNT=0
MAX_RESTARTS=5
while true; do
sleep 10
if is_9222_alive; then
# 正常:端末还在,继续监控
continue
fi
# 端口 9222 挂了
RESTART_COUNT=$((RESTART_COUNT + 1))
log "⚠️ 端口 9222 无响应(第 ${RESTART_COUNT} 次)"
if [ "$RESTART_COUNT" -gt "$MAX_RESTARTS" ]; then
log "❌ 已连续重启 ${MAX_RESTARTS} 次失败,退出等待手动处理"
log " 请检查: 系统内存是否充足(vm_stat),或手动启动 Chrome"
log " 手动命令: pkill -f 'remote-debugging-port=9222'"
log " $CHROME --remote-debugging-port=9222 --user-data-dir=$USER_DATA_DIR --no-first-run --no-default-browser-check about:blank &"
exit 2
fi
# 递增等待时间:5s → 15s → 30s → 30s → 30s
WAIT=$(( RESTART_COUNT <= 1 ? 5 : RESTART_COUNT == 2 ? 15 : 30 ))
log "等待 ${WAIT} 秒后重启..."
# 清理残留 Chrome 进程(防止多实例)
pkill -f "remote-debugging-port=9222" 2>/dev/null || true
sleep 2
# 打印内存状态帮助排查
memory_free=$(vm_stat | awk '/free/ {print $3}' | sed 's/\.//')
log "当前空闲内存页: ${memory_free:-?}"
launch_chrome
# 等最多 20 秒看有没有起来
for i in $(seq 1 20); do
sleep 1
if is_9222_alive; then
log "Chrome 重启成功(第 ${i} 秒)"
# 成功一次重置计数
RESTART_COUNT=0
break
fi
done
if ! is_9222_alive; then
log "❌ Chrome 重启失败(20 秒超时),继续下一轮"
fi
done
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""chrome_hide.py — 把 9222 采集 Chrome 的窗口移到屏幕外(CDP)
供 chrome_debug.sh 启动后调用。通过 Chrome DevTools Protocol 操作
9222 实例自己的窗口,绝不会影响用户日常 Chrome。
用法: python3 chrome_hide.py
"""
import asyncio
import sys
OFFSCREEN = {"left": 10000, "top": 10000, "width": 900, "height": 600, "windowState": "normal"}
async def main() -> int:
try:
from playwright.async_api import async_playwright
except ImportError:
print("[chrome_hide] playwright 未安装,跳过窗口隐藏", file=sys.stderr)
return 0
pw = await async_playwright().start()
try:
browser = await pw.chromium.connect_over_cdp("http://127.0.0.1:9222")
except Exception as e:
print(f"[chrome_hide] CDP 连接失败: {e}", file=sys.stderr)
await pw.stop()
return 1
try:
cdp = await browser.new_browser_cdp_session()
targets = await cdp.send("Target.getTargets")
moved = 0
for t in targets.get("targetInfos", []):
if t.get("type") != "page":
continue
try:
win = await cdp.send("Browser.getWindowForTarget", {"targetId": t["targetId"]})
wid = win.get("windowId")
if wid:
await cdp.send("Browser.setWindowBounds", {"windowId": wid, "bounds": OFFSCREEN})
moved += 1
except Exception as e:
print(f"[chrome_hide] 窗口 {t.get('targetId', '')} 处理失败: {e}", file=sys.stderr)
print(f"[chrome_hide] 已将 {moved} 个窗口移到屏幕外")
finally:
try:
await browser.close()
except Exception:
pass
await pw.stop()
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -0,0 +1,124 @@
#!/bin/bash
# guardd 安装脚本 — 动态生成 plist → 部署到 launchd,设置 300 秒周期
# 用法: bash scripts/install.sh
# 多机器兼容:自动探测本机路径,无需手动修改 plist 模板
set -euo pipefail
GUARDD_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PLIST_DEST="$HOME/Library/LaunchAgents/com.agentos.guardd.plist"
GUARDD_PY="$GUARDD_DIR/guardd.py"
RUNTIME_DIR="$HOME/workbuddy-agent-os/agent-local/runtime/guardd"
# ── 自动探测 Python ──────────────────────────────────────
# 优先使用 agent-os venv,fallback 到当前 python3
if [ -x "$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3" ]; then
PYTHON_BIN="$HOME/.workbuddy/binaries/python/envs/agent-os/bin/python3"
elif [ -x "$HOME/.workbuddy/binaries/python/envs/default/bin/python3" ]; then
PYTHON_BIN="$HOME/.workbuddy/binaries/python/envs/default/bin/python3"
else
PYTHON_BIN=$(command -v python3)
fi
PYTHON_LIB_DIR="$(dirname "$(dirname "$PYTHON_BIN")")/versions/$(basename "$(dirname "$(dirname "$PYTHON_BIN")")")/lib" 2>/dev/null || echo ""
echo "=== guardd 安装脚本 ==="
echo "源目录: $GUARDD_DIR"
echo "Python: $PYTHON_BIN"
# 1. 检查 guardd.py 是否存在
if [ ! -f "$GUARDD_PY" ]; then
echo "❌ 未找到 guardd.py: $GUARDD_PY"
echo " 请确保在 guardd 目录下运行此脚本"
exit 1
fi
# 2. 创建运行时目录
mkdir -p "$RUNTIME_DIR"
echo "✅ 运行时目录: $RUNTIME_DIR"
# 3. 确保 guardd.py 可执行
chmod +x "$GUARDD_PY"
echo "✅ guardd.py 可执行权限"
# 4. 动态生成 plist(多机器兼容,不依赖静态模板)
cat > "$PLIST_DEST" << PLEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.agentos.guardd</string>
<key>ProgramArguments</key>
<array>
<string>$PYTHON_BIN</string>
<string>$GUARDD_PY</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>${RUNTIME_DIR}/stdout.log</string>
<key>StandardErrorPath</key>
<string>${RUNTIME_DIR}/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
<key>GUARDD_DASHBOARD_URL</key>
<string>http://localhost:9988</string>
</dict>
<key>KeepAlive</key>
<false/>
<key>ThrottleInterval</key>
<integer>300</integer>
</dict>
</plist>
PLEOF
echo "✅ plist 已动态生成: $PLIST_DEST"
# 5. 卸载旧的 launchd job(如果存在)
launchctl bootout gui/$(id -u)/com.agentos.guardd 2>/dev/null || true
# 6. 加载新 plist
launchctl bootstrap gui/$(id -u) "$PLIST_DEST"
echo "✅ guardd 已加载到 launchd"
# 7. 立即启动一次(测试)
launchctl kickstart -k gui/$(id -u)/com.agentos.guardd
echo "✅ guardd 已启动"
# 8. 验证
sleep 2
if [ -f "$RUNTIME_DIR/last_run.json" ]; then
echo "✅ guardd 首次运行成功"
cat "$RUNTIME_DIR/last_run.json"
else
echo "⏳ guardd 尚未完成首次运行,等待 10 秒..."
sleep 8
if [ -f "$RUNTIME_DIR/last_run.json" ]; then
echo "✅ guardd 首次运行成功"
cat "$RUNTIME_DIR/last_run.json"
else
echo "⚠️ 未检测到 last_run.json,请检查日志: $RUNTIME_DIR/guardd.log"
if [ -f "$RUNTIME_DIR/stderr.log" ]; then
echo "--- stderr ---"
cat "$RUNTIME_DIR/stderr.log"
fi
fi
fi
echo ""
echo "=== guardd 安装完成 ==="
echo "查看状态: cat $RUNTIME_DIR/last_run.json"
echo "查看日志: cat $RUNTIME_DIR/guardd.log"
echo "手动卸载: launchctl bootout gui/$(id -u) $PLIST_DEST"
@@ -0,0 +1,43 @@
# 7kecheng → chengzigedeAir SSH 配置
> 最后更新:2026-06-01 20:15
## 连接信息
| 项目 | 值 |
|------|-----|
| 主机 | chengzigedeAir |
| IP | 192.168.31.101 |
| 端口 | 22 |
| 用户 | chengzige |
| 密钥 | `id_ed25519_chengzigedeAir` |
## 7kecheng 上执行
### 方式一:通过 Git 仓库获取私钥
本机私钥文件路径: `~/.ssh/id_ed25519_7kecheng`,需要通过安全通道传递给 7kecheng。
### 方式二:手动复制
在 7kecheng 上创建一个新的 SSH 密钥对,然后把公钥发过来:
```bash
# 在 7kecheng 上生成密钥
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_chengzigedeAir -N "" -C "7kecheng-to-chengzigedeAir"
cat ~/.ssh/id_ed25519_chengzigedeAir.pub
# 把输出的公钥发给 chengzigedeAir 加入到 ~/.ssh/authorized_keys
```
### 方式三:在 7kecheng 获取私钥内容
```
# 在 7kecheng 上执行:
# 复制以下完整内容到 ~/.ssh/id_ed25519_chengzigedeAir
```
## 公钥(已加入 chengzigedeAir 的 authorized_keys)
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL9bYcGz5IsDKOEpyYKWsJj+CO8qF9fftK/Dtj2ZEZw8 7kecheng@chengzigedeAir
```
@@ -0,0 +1,76 @@
#!/bin/bash
# ─────────────────────────────────────────────────
# 多机同步脚本 — 拉取最新代码 + 重建依赖
# 在 7kecheng / 5kechengdeAir 上各执行一次
# ─────────────────────────────────────────────────
set -e
echo "════════════════════════════════════════"
echo " AgentOS 多机同步 $(date '+%Y-%m-%d %H:%M')"
echo "════════════════════════════════════════"
# ── 1. 拉取最新代码 ──
echo ""
echo "=== 1. 拉取最新代码 ==="
cd ~/workbuddy-agent-os/agent-sync
git pull --rebase origin main
echo " ✅ 代码已更新 ($(git log --oneline -1))"
# ── 2. 重建 Dashboard venv ──
echo ""
echo "=== 2. 重建 Dashboard venv ==="
VENV=~/.workbuddy/binaries/python/envs/dashboard
rm -rf "$VENV"
# 使用 managed Python 3.13(如果不可用则用 system Python)
PY_BIN=~/.workbuddy/binaries/python/versions/3.13.12/bin/python3
if [ ! -f "$PY_BIN" ]; then
PY_BIN=/usr/bin/python3
fi
echo " 使用 Python: $($PY_BIN --version)"
$PY_BIN -m venv "$VENV"
$VENV/bin/pip install fastapi uvicorn pyyaml -q
echo " ✅ Dashboard venv 就绪"
# ── 3. 更新 launchd 配置 ──
echo ""
echo "=== 3. 更新 launchd 配置 ==="
# Dashboard
cp ~/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/com.agentos.dashboard.plist \
~/Library/LaunchAgents/com.agentos.dashboard.plist 2>/dev/null || true
# SOCKS5 转发器
cp ~/workbuddy-agent-os/agent-sync/05_tools/00_setup/guardd/com.agentos.socks5-forwarder.plist \
~/Library/LaunchAgents/com.agentos.socks5-forwarder.plist 2>/dev/null || true
echo " ✅ launchd 配置已更新"
# ── 4. 重启服务 ──
echo ""
echo "=== 4. 重启服务 ==="
# 重启 Dashboard
launchctl bootout gui/$(id -u)/com.agentos.dashboard 2>/dev/null || true
sleep 1
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agentos.dashboard.plist 2>/dev/null || \
launchctl load ~/Library/LaunchAgents/com.agentos.dashboard.plist 2>/dev/null || true
# guardd 下次心跳会自动更新
echo " ✅ 服务已重启"
# ── 5. 验证 ──
echo ""
echo "=== 5. 验证 ==="
sleep 3
curl -s http://localhost:9988/api/machines 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(f' Dashboard: {d[\"total\"]} 台机器在线')
for m in d['machines']:
print(f' {m[\"hostname\"]:20s} {m[\"status\"]}')
" 2>/dev/null || echo " ⚠️ Dashboard 启动中,稍后刷新 http://localhost:9988"
echo ""
echo "════════════════════════════════════════"
echo " ✅ 同步完成!"
echo "════════════════════════════════════════"