feat: 线程操作与 skills/MCP 管理面暴露(host CLI + 服务端 API + 控制台)

线程:host.py 新增 thread-list/read/rename/archive/unarchive/compact 子命令(纯 JSON stdout 契约,read/rename/unarchive/compact 先 resume);服务端按任务定 scope(thread/list 的 cwd 过滤解析,Docker/cloud/异机 409,改操作仅终态,审计 thread_*);控制台线程区(元数据、列表、重命名、归档、取消归档、压缩,终态门控)。

skills/MCP:host.py 新增 skills-list/apps-list/mcp-servers-list/mcp-servers-reload/mcp-server-oauth-login 子命令;服务端按项目 scope(读 read,改 manage_project,审计 mcp_*);控制台技能与 MCP 区(列表、重载、OAuth 发起展示,绝不谎称登录完成)。

测试:host 84、cloud 687(4 跳过)、deploy 19 全过。诚实边界:Docker 任务线程在容器内故 409;无仓库任务推不出 cwd 时 404;OAuth 只做到发起透传,完成态靠协议通知;merged/merge-failed 改操作 409(比控制台门控窄,服务端报错原文展示)。
This commit is contained in:
host committed 2026-10-27 16:37:28 +00:00
1 parent 154c18e25d
commit ef532a38c2
7 files changed
+2128 -2

No files matched your search

+164
View File
@@ -2065,6 +2065,16 @@ def _resolve_path(base_dir, p):
return os.path.join(base_dir, p)
def _thread_cli_result(resp, method):
# thread-* 子命令的冻结契约:成功只打 result 内容,失败由调用方
# 打 error JSON 并以非零退出;result 为空时打空对象,保持 stdout
# 始终是一个 JSON 对象。
if not resp or "error" in resp:
raise RuntimeError(f"{method} 失败:{resp}")
result = resp.get("result")
return result if result is not None else {}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--codex-home", default=None)
@@ -2143,6 +2153,17 @@ def main():
help="评审目标:指定提交 sha(commit)")
p.add_argument("--instructions", default=None,
help="评审目标:自定义评审指令(custom)")
p = sub.add_parser("thread-list"); p.add_argument("--cwd", default=None); p.add_argument("--archived", action="store_true")
p = sub.add_parser("thread-read"); p.add_argument("--thread-id", required=True); p.add_argument("--include-turns", action="store_true")
p = sub.add_parser("thread-rename"); p.add_argument("--thread-id", required=True); p.add_argument("--name", required=True)
p = sub.add_parser("thread-archive"); p.add_argument("--thread-id", required=True)
p = sub.add_parser("thread-unarchive"); p.add_argument("--thread-id", required=True)
p = sub.add_parser("thread-compact"); p.add_argument("--thread-id", required=True)
p = sub.add_parser("skills-list"); p.add_argument("--cwd", default=None)
p = sub.add_parser("apps-list")
p = sub.add_parser("mcp-servers-list")
p = sub.add_parser("mcp-servers-reload")
p = sub.add_parser("mcp-server-oauth-login"); p.add_argument("--server-name", required=True); p.add_argument("--scopes", default=None); p.add_argument("--timeout-secs", type=int, default=None)
p = sub.add_parser("run"); p.add_argument("--task", required=True); p.add_argument("--prompt", required=True)
p = sub.add_parser("interrupt"); p.add_argument("--task", required=True)
p = sub.add_parser("delegate")
@@ -2592,6 +2613,149 @@ def main():
t["status"] = "discarded"
save_state(args.state, state)
print(f"discarded {args.task}")
elif args.cmd == "thread-list":
# 只读命令,不 resume;cwd/archived 只在给了时才进 params,
# 不传时服务端按自身缺省过滤。
try:
params = {}
if args.cwd:
params["cwd"] = args.cwd
if args.archived:
params["archived"] = True
resp = srv.request("thread/list", params, timeout=60)
print(json.dumps(_thread_cli_result(resp, "thread/list"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "thread-read":
try:
srv.resume_thread(args.thread_id)
resp = srv.request("thread/read",
{"threadId": args.thread_id,
"includeTurns": bool(args.include_turns)},
timeout=60)
print(json.dumps(_thread_cli_result(resp, "thread/read"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "thread-rename":
try:
srv.resume_thread(args.thread_id)
resp = srv.request("thread/name/set",
{"threadId": args.thread_id,
"name": args.name},
timeout=60)
print(json.dumps(_thread_cli_result(resp, "thread/name/set"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "thread-archive":
# 沿用现有 archive 行为:不 resume,直接调 thread/archive。
try:
resp = srv.archive_thread(args.thread_id)
print(json.dumps(_thread_cli_result(resp, "thread/archive"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "thread-unarchive":
try:
srv.resume_thread(args.thread_id)
resp = srv.request("thread/unarchive",
{"threadId": args.thread_id},
timeout=60)
print(json.dumps(_thread_cli_result(resp, "thread/unarchive"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "thread-compact":
try:
srv.resume_thread(args.thread_id)
resp = srv.request("thread/compact/start",
{"threadId": args.thread_id},
timeout=120)
print(json.dumps(_thread_cli_result(resp, "thread/compact/start"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "skills-list":
# skills/mcp 管理面与 thread-* 子命令同款冻结契约:成功
# 只打 result 内容,失败打 error JSON 并以非零退出。
# skills/list 等方法不带 threadId,不需要 resume。
try:
params = {}
if args.cwd:
params["cwds"] = [args.cwd]
resp = srv.request("skills/list", params, timeout=60)
print(json.dumps(_thread_cli_result(resp, "skills/list"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "apps-list":
try:
resp = srv.request("app/list", {}, timeout=60)
print(json.dumps(_thread_cli_result(resp, "app/list"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "mcp-servers-list":
try:
resp = srv.request("mcpServerStatus/list", {}, timeout=60)
print(json.dumps(_thread_cli_result(resp, "mcpServerStatus/list"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "mcp-servers-reload":
try:
resp = srv.request("config/mcpServer/reload", None, timeout=60)
print(json.dumps(_thread_cli_result(resp, "config/mcpServer/reload"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "mcp-server-oauth-login":
try:
params = {"name": args.server_name}
if args.scopes:
params["scopes"] = [s for s in args.scopes.split(",") if s]
if args.timeout_secs is not None:
params["timeoutSecs"] = args.timeout_secs
resp = srv.request("mcpServer/oauth/login", params, timeout=120)
print(json.dumps(_thread_cli_result(resp, "mcpServer/oauth/login"),
ensure_ascii=False))
except SystemExit:
raise
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
raise SystemExit(1)
elif args.cmd == "compact":
t = state["tasks"][args.task]
# 注意:compact 要求 thread 已在当前 server 里加载,