ymai-codeflow 0.0.0-stage → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Launcher: delegates to the bundled Python executor runtime (stdlib-only, Python 3.11+).
4
+ const { spawn } = require('child_process');
5
+ const path = require('path');
6
+
7
+ const packageRoot = path.join(__dirname, '..');
8
+ const runtimeDir = path.join(packageRoot, 'runtime');
9
+ const args = process.argv.slice(2);
10
+ const known = new Set(['connect', '--once-smoke', '--check-config', '--help']);
11
+ if (args.length === 0 || !known.has(args[0])) {
12
+ console.error('Usage: ymai-codeflow connect --ws-url <url> --token <token> --executor-id <id> [--provider claude] [--memory-mode platform] [--max-tasks N]');
13
+ console.error(' ymai-codeflow --check-config | --once-smoke | --help');
14
+ console.error('Requires Python 3.11+ on the target machine.');
15
+ process.exit(args.length === 0 ? 2 : 1);
16
+ }
17
+
18
+ const pythonCandidates = process.env.AINATIVE_PYTHON
19
+ ? [process.env.AINATIVE_PYTHON]
20
+ : ['python3', 'python3.11', 'python'];
21
+
22
+ let child;
23
+ (function tryNext(i) {
24
+ if (i >= pythonCandidates.length) {
25
+ console.error('Python 3.11+ not found. Install Python 3.11+ or set AINATIVE_PYTHON=/path/to/python3.');
26
+ process.exit(1);
27
+ }
28
+ const py = pythonCandidates[i];
29
+ child = spawn(py, ['-m', 'runtime.daemon', ...args], {
30
+ cwd: packageRoot,
31
+ stdio: 'inherit',
32
+ env: process.env,
33
+ });
34
+ child.on('error', (err) => {
35
+ if (err.code === 'ENOENT') { tryNext(i + 1); return; }
36
+ console.error(err.message);
37
+ process.exit(1);
38
+ });
39
+ child.on('exit', (code2) => process.exit(code2 ?? 0));
40
+ })(0);
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "ymai-codeflow",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.30",
4
+ "description": "CodeFlow executor runtime launcher — connects AI worker daemons to the CodeFlow platform (Python 3.11+ required).",
5
+ "bin": {
6
+ "ymai-codeflow": "./bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "runtime/"
11
+ ],
12
+ "keywords": ["codeflow", "executor", "agent", "ai", "delivery"],
13
+ "license": "Apache-2.0",
14
+ "engines": { "node": ">=16" },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/qsumm/CodeFlow.git"
18
+ }
19
+ }
File without changes
@@ -0,0 +1,162 @@
1
+ """跨角色接棒:经平台 REST API(架构许可通道,见 SdlcDriver javadoc / docs/10 v2)。
2
+
3
+ 配置(runtime.env):
4
+ RUNTIME_HANDOFF_AGENT_ID 下一角色 agentId(必填才启用接棒)
5
+ AINATIVE_API_BASE_URL 默认 http://localhost:7001
6
+ AINATIVE_WORKSPACE_ID 工作空间 ID
7
+ AINATIVE_REFRESH_TOKEN 平台 refresh token(401 时自动换取新 access token)
8
+ AINATIVE_ACCESS_TOKEN 当前 access token(可选,建议给 refresh token 即可)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ _state = {"access": None, "refresh": None, "base": None, "ws": None}
17
+
18
+
19
+ def _put(path: str, body: dict, token: str | None = None) -> dict:
20
+ req = urllib.request.Request(_state["base"] + path, method="PUT",
21
+ data=json.dumps(body).encode())
22
+ req.add_header("Content-Type", "application/json")
23
+ if token:
24
+ req.add_header("Authorization", f"Bearer {token}")
25
+ with urllib.request.urlopen(req, timeout=15) as r:
26
+ return json.loads(r.read())
27
+
28
+
29
+ def _post(path: str, body: dict, token: str | None = None) -> dict:
30
+ req = urllib.request.Request(_state["base"] + path, method="POST",
31
+ data=json.dumps(body).encode())
32
+ req.add_header("Content-Type", "application/json")
33
+ if token:
34
+ req.add_header("Authorization", f"Bearer {token}")
35
+ with urllib.request.urlopen(req, timeout=15) as r:
36
+ return json.loads(r.read())
37
+
38
+
39
+ def _ensure_token(cfg) -> str:
40
+ if _state["access"] is None:
41
+ _state["access"] = cfg.extra_env.get("AINATIVE_ACCESS_TOKEN") or None
42
+ _state["refresh"] = cfg.extra_env.get("AINATIVE_REFRESH_TOKEN")
43
+ _state["base"] = cfg.extra_env.get("AINATIVE_API_BASE_URL", "http://localhost:7001")
44
+ if _state["access"] is None and _state["refresh"]:
45
+ d = _post("/api/auth/refresh", {"refreshToken": _state["refresh"],
46
+ "workspaceId": int(cfg.extra_env.get("AINATIVE_WORKSPACE_ID", 0))})["data"]
47
+ _state["access"] = d["accessToken"]
48
+ if _state["access"] is None:
49
+ raise RuntimeError("no API token: set AINATIVE_ACCESS_TOKEN/REFRESH_TOKEN")
50
+ return _state["access"]
51
+
52
+
53
+ def _switch_workspace_token(cfg) -> str | None:
54
+ """把平台级令牌换成工作区级令牌(DEF-028)。
55
+
56
+ 平台登录返回的 access token 是"平台级"的,直接调 /api/workitems/{id} 会被拒
57
+ (HTTP 403,code 11001「非该工作空间成员」)。工作区级令牌需先调用
58
+ POST /api/workspaces/{id}/switch 换取。
59
+ """
60
+ ws = cfg.extra_env.get("AINATIVE_WORKSPACE_ID")
61
+ if not ws:
62
+ return None
63
+ base = _state["base"]
64
+ current = _state["access"] or _state["refresh"]
65
+ req = urllib.request.Request(f"{base}/api/workspaces/{int(ws)}/switch", method="POST",
66
+ data=b"{}")
67
+ req.add_header("Content-Type", "application/json")
68
+ if current:
69
+ req.add_header("Authorization", f"Bearer {current}")
70
+ with urllib.request.urlopen(req, timeout=15) as r:
71
+ data = json.loads(r.read()).get("data") or {}
72
+ token = data.get("accessToken")
73
+ if token:
74
+ _state["access"] = token
75
+ return token
76
+
77
+
78
+ def _with_refresh_retry(cfg, fn):
79
+ try:
80
+ return fn(_ensure_token(cfg))
81
+ except urllib.error.HTTPError as e:
82
+ if e.code == 403 and _state["ws"] is None:
83
+ # 平台级令牌 → 先换工作区级令牌再试一次(DEF-028)
84
+ ws = cfg.extra_env.get("AINATIVE_WORKSPACE_ID")
85
+ if ws:
86
+ _state["ws"] = ws
87
+ try:
88
+ _switch_workspace_token(cfg)
89
+ return fn(_ensure_token(cfg))
90
+ except Exception:
91
+ pass
92
+ raise
93
+ if e.code != 401 or not _state["refresh"]:
94
+ raise
95
+ _state["access"] = None
96
+ return fn(_ensure_token(cfg))
97
+
98
+
99
+ def _get(path: str, token: str) -> dict:
100
+ req = urllib.request.Request(_state["base"] + path, method="GET")
101
+ req.add_header("Authorization", f"Bearer {token}")
102
+ with urllib.request.urlopen(req, timeout=15) as r:
103
+ return json.loads(r.read())
104
+
105
+
106
+ def finalize_to_done(cfg, workitem_id: int) -> None:
107
+ """把工单流转到其状态模板的 DONE 节点(链路终点收敛,等价于页面上的完工流转)。"""
108
+ def call(tok):
109
+ w = _get(f"/api/workitems/{workitem_id}", tok)["data"]
110
+ nodes = _get(f"/api/status-templates/{w['templateId']}/nodes", tok)["data"]
111
+ done = next((n for n in nodes if str(n.get("category", "")).upper() == "DONE"), None)
112
+ if done is None:
113
+ raise RuntimeError("no DONE node in status template")
114
+ if done["id"] == w.get("statusNodeId"):
115
+ return {"success": True, "message": "already at DONE"}
116
+ body = {"toNodeId": done["id"], "fromNodeId": w.get("statusNodeId"),
117
+ "expectedVersion": w.get("version")}
118
+ return _post(f"/api/workitems/{workitem_id}/transition", body, tok)
119
+ resp = _with_refresh_retry(cfg, call)
120
+ if not resp.get("success"):
121
+ raise RuntimeError(f"finalize failed: {resp.get('message')}")
122
+
123
+
124
+ def handoff_to_human(cfg, workitem_id: int, user_id: int | None = None) -> None:
125
+ """把工单交还真人(平台合法操作:assigneeType=HUMAN + 真人 userId)。"""
126
+ uid = user_id if user_id is not None else cfg.extra_env.get("AINATIVE_HUMAN_USER_ID")
127
+ if uid is None:
128
+ raise RuntimeError("no human user id for handoff (roster/ AINATIVE_HUMAN_USER_ID)")
129
+ def call(tok):
130
+ return _put(f"/api/workitems/{workitem_id}/assignee",
131
+ {"assigneeType": "HUMAN", "assigneeRef": int(uid)}, tok)
132
+ resp = _with_refresh_retry(cfg, call)
133
+ if not resp.get("success"):
134
+ raise RuntimeError(f"handoff_to_human failed: {resp.get('message')}")
135
+
136
+
137
+ def reassign_to_agent(cfg, workitem_id: int, agent_id: int) -> None:
138
+ """把工单重新指派给下一角色的数字员工(平台合法操作,等价于项目经理在页面点指派)。"""
139
+ cfg.extra_env.setdefault("AINATIVE_API_BASE_URL", "http://localhost:7001")
140
+ _state["base"] = cfg.extra_env["AINATIVE_API_BASE_URL"]
141
+ body = {"assigneeType": "AGENT", "assigneeRef": agent_id}
142
+ def call(tok):
143
+ return _put(f"/api/workitems/{workitem_id}/assignee", body, tok)
144
+ resp = _with_refresh_retry(cfg, call)
145
+ if not resp.get("success"):
146
+ raise RuntimeError(f"reassign failed: {resp.get('message')}")
147
+
148
+
149
+ def agent_comment(cfg, dispatch_id: int, content_md: str) -> dict:
150
+ """数字员工备注:走平台 daemon 备注通道(executor token 认证,免 Bearer)。
151
+
152
+ 平台侧为该备注触发 WorkitemCommentCreatedEvent → outbox COMMENT_CREATE →
153
+ 外部工单平台(禅道等)备注回写。这是"交付结论回到源头平台"的正式通道。
154
+ """
155
+ base = cfg.extra_env.get("AINATIVE_API_BASE_URL", "http://localhost:7001")
156
+ _state["base"] = base
157
+ path = f"/api/daemon/dispatches/{int(dispatch_id)}/comments?token={cfg.executor_token}"
158
+ req = urllib.request.Request(base + path, method="POST",
159
+ data=json.dumps({"contentMd": content_md}).encode())
160
+ req.add_header("Content-Type", "application/json")
161
+ with urllib.request.urlopen(req, timeout=15) as r:
162
+ return json.loads(r.read())
@@ -0,0 +1,94 @@
1
+ """CodeFlow runtime - configuration (env only, stdlib)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ class ConfigError(Exception):
10
+ pass
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ server_ws_url: str
16
+ executor_id: int
17
+ executor_token: str
18
+ provider: str = "mock"
19
+ agent_cmd: str = "claude"
20
+ agent_timeout_sec: int = 1800
21
+ agent_max_turns: int = 40
22
+ agent_allowed_tools: str = ""
23
+ handoff_agent_id: int | None = None
24
+ chain_finalize: bool = False
25
+ handoff_human_on_fail: bool = False
26
+ data_dir: str = "./runtime-data"
27
+ heartbeat_sec: int = 30
28
+ max_concurrent: int = 1
29
+ log_level: str = "info"
30
+ powercontext_base_url: str = ""
31
+ powercontext_timeout_sec: float = 8.0
32
+ powercontext_remember: bool = True
33
+ extra_env: dict = field(default_factory=dict)
34
+
35
+ @property
36
+ def ws_path(self) -> str:
37
+ # ws://host:port/ws/executor -> path with query appended later
38
+ parts = self.server_ws_url.split("://", 1)
39
+ if len(parts) != 2 or parts[0] not in ("ws", "wss"):
40
+ raise ConfigError(f"AINATIVE_SERVER_WS_URL must start with ws:// or wss://: {self.server_ws_url}")
41
+ hostpath = parts[1]
42
+ path = "/" + hostpath.split("/", 1)[1] if "/" in hostpath else "/ws/executor"
43
+ if path.rstrip("/") != "/ws/executor":
44
+ raise ConfigError(f"WS path must be /ws/executor, got: {path}")
45
+ return path
46
+
47
+ @property
48
+ def ws_hostport(self) -> str:
49
+ return self.server_ws_url.split("://", 1)[1].split("/", 1)[0]
50
+
51
+ @property
52
+ def use_tls(self) -> bool:
53
+ return self.server_ws_url.startswith("wss://")
54
+
55
+
56
+ def load_config(env: dict | None = None) -> Config:
57
+ env = dict(os.environ if env is None else env)
58
+ missing = [k for k in ("AINATIVE_SERVER_WS_URL", "AINATIVE_EXECUTOR_ID", "AINATIVE_EXECUTOR_TOKEN") if not env.get(k)]
59
+ if missing:
60
+ raise ConfigError("missing required env: " + ", ".join(missing))
61
+ provider = env.get("AINATIVE_PROVIDER", "mock")
62
+ if provider not in ("mock", "claude", "gemini", "codex", "cursor"):
63
+ raise ConfigError(f"AINATIVE_PROVIDER must be mock|claude|gemini|codex|cursor, got: {provider}")
64
+ # 各 provider 的默认 CLI 命令;可用 AINATIVE_AGENT_CMD 覆盖(绝对路径/自定义名)。
65
+ cfg = Config(
66
+ server_ws_url=env["AINATIVE_SERVER_WS_URL"],
67
+ executor_id=int(env["AINATIVE_EXECUTOR_ID"]),
68
+ executor_token=env["AINATIVE_EXECUTOR_TOKEN"],
69
+ provider=provider,
70
+ agent_cmd=env.get("AINATIVE_AGENT_CMD",
71
+ {"claude": "claude", "gemini": "gemini", "codex": "codex",
72
+ "cursor": "cursor-agent"}.get(provider, "claude")),
73
+ agent_timeout_sec=int(env.get("AINATIVE_AGENT_TIMEOUT_SEC", "1800")),
74
+ agent_max_turns=int(env.get("AINATIVE_AGENT_MAX_TURNS", "40")),
75
+ agent_allowed_tools=env.get("AINATIVE_AGENT_ALLOWED_TOOLS", ""),
76
+ handoff_agent_id=int(env["RUNTIME_HANDOFF_AGENT_ID"]) if env.get("RUNTIME_HANDOFF_AGENT_ID") else None,
77
+ chain_finalize=env.get("RUNTIME_CHAIN_FINALIZE", "0") in ("1", "true", "yes"),
78
+ handoff_human_on_fail=env.get("RUNTIME_HANDOFF_HUMAN_ON_FAIL", "0") in ("1", "true", "yes"),
79
+ # 绝对化:工作目录会作为 claude CLI 的 cwd,相对路径会让 --settings 解析失败(DEF-026)
80
+ data_dir=str(Path(env.get("RUNTIME_DATA_DIR", "./runtime-data")).expanduser().resolve()),
81
+ heartbeat_sec=int(env.get("RUNTIME_HEARTBEAT_SEC", "30")),
82
+ max_concurrent=int(env.get("RUNTIME_MAX_CONCURRENT", "1")),
83
+ log_level=env.get("RUNTIME_LOG_LEVEL", "info"),
84
+ # PowerContext 记忆外挂(可选):不配 POWERCONTEXT_BASE_URL 即关闭
85
+ powercontext_base_url=(env.get("POWERCONTEXT_BASE_URL") or "").strip().rstrip("/"),
86
+ powercontext_timeout_sec=float(env.get("POWERCONTEXT_TIMEOUT_SEC") or "8.0"),
87
+ powercontext_remember=env.get("POWERCONTEXT_REMEMBER", "1") in ("1", "true", "yes"),
88
+ extra_env={k: v for k, v in env.items()
89
+ if k.startswith(("ANTHROPIC_", "AINATIVE_AGENT_"))
90
+ or k in ("AINATIVE_ACCESS_TOKEN", "AINATIVE_REFRESH_TOKEN",
91
+ "AINATIVE_API_BASE_URL", "AINATIVE_WORKSPACE_ID")},
92
+ )
93
+ cfg.ws_path # validate URL shape eagerly
94
+ return cfg
@@ -0,0 +1,220 @@
1
+ """Clarification-conversation turn execution (CONVERSATION_TURN frames).
2
+
3
+ 协议(平台侧 WsConversationTransport/InboundFrameRouter):
4
+ 平台 → runtime: CONVERSATION_TURN{conversationId,turnId,agentId,dispatchAttempt?,
5
+ content,requestId,cliSessionRef?,systemPrompt?,capabilityDownloadUrl,
6
+ capabilitySha256,capabilityHash,mcpToken}
7
+ runtime → 平台: CONVERSATION_TURN_ACK{conversationId,turnId,dispatchAttempt?,
8
+ status:SUCCESS|FAILED,error?,replyMarkdown?,sessionId?,usage?}
9
+
10
+ 心跳声明 CONVERSATION_DELIVERY_ACK_V1 后,ACK 必须回显 dispatchAttempt,
11
+ 平台据此做陈旧投递判定与投递确认。
12
+
13
+ claude 执行形态:每会话一个工作区;每回合下载 capability 包(sha256 校验)解包,
14
+ --resume 上一回合 sessionId 保持上下文;MCP(ainative) 以 Bearer mcpToken 注入,
15
+ 数字员工由此读取工单与平台数据。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import subprocess
22
+ import time
23
+ import urllib.request
24
+ import zipfile
25
+ from pathlib import Path
26
+
27
+ from .logging_util import log
28
+ from .providers.claude_code import write_settings_file
29
+
30
+ CAPABILITY_TIMEOUT_SEC = 120
31
+ TURN_TIMEOUT_SEC = 30 * 60 # 平台 ~11min 会重投;dispatchAttempt 去重保证幂等
32
+
33
+
34
+ def download_capability(url: str, expected_sha256: str, dest_dir: Path) -> Path:
35
+ """下载 capability 包并做 sha256 完整性校验(与平台 TaskPackager 计算口径一致)。"""
36
+ dest_dir.mkdir(parents=True, exist_ok=True)
37
+ zip_path = dest_dir / "capability.zip"
38
+ tmp = zip_path.with_suffix(".part")
39
+ req = urllib.request.Request(url)
40
+ with urllib.request.urlopen(req, timeout=CAPABILITY_TIMEOUT_SEC) as resp, open(tmp, "wb") as f:
41
+ while True:
42
+ chunk = resp.read(256 * 1024)
43
+ if not chunk:
44
+ break
45
+ f.write(chunk)
46
+ digest = hashlib.sha256(tmp.read_bytes()).hexdigest()
47
+ if expected_sha256 and digest.lower() != expected_sha256.lower():
48
+ tmp.unlink(missing_ok=True)
49
+ raise ValueError(f"capability sha256 mismatch: got {digest}, want {expected_sha256}")
50
+ tmp.replace(zip_path)
51
+ return zip_path
52
+
53
+
54
+ def extract_capability(zip_path: Path, dest_dir: Path) -> None:
55
+ """解包 capability 包(覆盖式;包内为 skills.json/mcp 配置/仓库上下文等)。"""
56
+ dest_dir.mkdir(parents=True, exist_ok=True)
57
+ with zipfile.ZipFile(zip_path) as zf:
58
+ zf.extractall(dest_dir)
59
+
60
+
61
+ def ainative_mcp_server(capsule_dir: Path, mcp_token: str) -> dict | None:
62
+ """从 capability 的 skills.json 取内置 ainative MCP 描述,注入 Bearer mcpToken。
63
+
64
+ 平台 authType=ainative-dispatch 的鉴权即 MCP 端点的 Authorization 头。
65
+ """
66
+ skills_path = capsule_dir / "skills.json"
67
+ if not skills_path.exists():
68
+ return None
69
+ try:
70
+ doc = json.loads(skills_path.read_text(encoding="utf-8"))
71
+ except (ValueError, OSError) as e:
72
+ log.warn("conversation.skills_unreadable", error=str(e))
73
+ return None
74
+ for server in doc.get("mcpServers") or []:
75
+ name = str(server.get("name", "")).lower()
76
+ if name != "ainative":
77
+ continue
78
+ url = server.get("url")
79
+ if not url:
80
+ return None
81
+ return {"type": "http" if server.get("transport", "http") == "http" else "sse",
82
+ "url": url, "headers": {"Authorization": f"Bearer {mcp_token}"}}
83
+ return None
84
+
85
+
86
+ def write_mcp_config(workdir: Path, server: dict | None) -> None:
87
+ if not server:
88
+ return
89
+ (workdir / ".mcp.json").write_text(
90
+ json.dumps({"mcpServers": {"ainative": server}}, ensure_ascii=False), encoding="utf-8")
91
+
92
+
93
+ def write_conversation_settings(workdir: Path, env: dict, mcp_token: str | None) -> str | None:
94
+ """claude --settings:钉模型端点环境变量 + 预授权 ainative MCP 工具。
95
+
96
+ -p 模式无权限弹窗,部分 CLI 版本对 --allowedTools 的服务器级简写不生效,
97
+ settings 的 permissions.allow(服务器名 + 工具通配两种形式)是权威路径。
98
+ """
99
+ picked = {k: v for k, v in env.items()
100
+ if k.startswith("ANTHROPIC_") or k == "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"}
101
+ settings = {"env": picked}
102
+ if mcp_token:
103
+ settings["permissions"] = {"allow": ["mcp__ainative", "mcp__ainative__*"]}
104
+ path = workdir / "claude-settings.json"
105
+ path.write_text(json.dumps(settings, ensure_ascii=False), encoding="utf-8")
106
+ return str(path)
107
+
108
+
109
+ def build_command(cli_binary: str, content: str, *, session_ref: str | None,
110
+ system_prompt: str | None, settings_file: str | None,
111
+ max_turns: int, allowed_tools: str | None = None) -> list[str]:
112
+ argv = [cli_binary, "-p", content, "--output-format", "json"]
113
+ if session_ref:
114
+ argv += ["--resume", session_ref]
115
+ if max_turns:
116
+ argv += ["--max-turns", str(max_turns)]
117
+ if settings_file:
118
+ argv += ["--settings", settings_file]
119
+ if system_prompt:
120
+ argv += ["--append-system-prompt", system_prompt]
121
+ if allowed_tools:
122
+ argv += ["--allowedTools", allowed_tools]
123
+ return argv
124
+
125
+
126
+ def parse_result(raw: str) -> dict:
127
+ """解析 claude --output-format json 单行结果 → {text, session_id, is_error, usage}。"""
128
+ out = {"text": "", "session_id": None, "is_error": True, "error": "empty output",
129
+ "usage": None, "model": None}
130
+ for line in (raw or "").splitlines():
131
+ line = line.strip()
132
+ if not line or not line.startswith("{"):
133
+ continue
134
+ try:
135
+ data = json.loads(line)
136
+ except ValueError:
137
+ continue
138
+ if data.get("type") != "result":
139
+ continue
140
+ out["text"] = data.get("result") or ""
141
+ out["session_id"] = data.get("session_id")
142
+ out["is_error"] = bool(data.get("is_error"))
143
+ out["error"] = None if not out["is_error"] else (out["text"] or "claude reported error")
144
+ usage = data.get("usage") or {}
145
+ out["usage"] = {
146
+ "inputTokens": int(usage.get("input_tokens") or 0),
147
+ "outputTokens": int(usage.get("output_tokens") or 0),
148
+ "cacheReadTokens": int(usage.get("cache_read_input_tokens") or 0),
149
+ "cacheWriteTokens": int(usage.get("cache_creation_input_tokens") or 0),
150
+ }
151
+ model_usage = data.get("modelUsage") or {}
152
+ if isinstance(model_usage, dict) and model_usage:
153
+ out["model"] = next(iter(model_usage))
154
+ break
155
+ return out
156
+
157
+
158
+ def run_claude_turn(cli_binary: str, workdir: Path, content: str, *, session_ref: str | None,
159
+ system_prompt: str | None, settings_path: str | None, max_turns: int,
160
+ env: dict, timeout_sec: int = TURN_TIMEOUT_SEC,
161
+ allowed_tools: str | None = None) -> dict:
162
+ """同步执行一回合对话;返回 parse_result 形态,附 exit_code/stderr 供失败诊断。"""
163
+ argv = build_command(cli_binary, content, session_ref=session_ref,
164
+ system_prompt=system_prompt, settings_file=settings_path,
165
+ max_turns=max_turns, allowed_tools=allowed_tools)
166
+ started = time.monotonic()
167
+ try:
168
+ proc = subprocess.run(argv, cwd=str(workdir), env=env, capture_output=True,
169
+ text=True, timeout=timeout_sec)
170
+ result = parse_result(proc.stdout or "")
171
+ result["exit_code"] = proc.returncode
172
+ if proc.returncode != 0 and not result["text"]:
173
+ result["is_error"] = True
174
+ result["error"] = (proc.stderr or "")[-800:] or f"claude exited {proc.returncode}"
175
+ except subprocess.TimeoutExpired:
176
+ result = {"text": "", "session_id": None, "is_error": True,
177
+ "error": f"conversation turn timeout after {timeout_sec}s",
178
+ "usage": None, "model": None, "exit_code": None}
179
+ result["duration_ms"] = int((time.monotonic() - started) * 1000)
180
+ return result
181
+
182
+
183
+ def turn_ack(conversation_id: int, turn_id: int, dispatch_attempt: int | None,
184
+ status: str, *, reply_markdown: str = "", error: str = "",
185
+ session_id: str | None = None, usage: dict | None = None,
186
+ model: str | None = None) -> dict:
187
+ frame = {"type": "CONVERSATION_TURN_ACK", "conversationId": int(conversation_id),
188
+ "turnId": int(turn_id), "status": status,
189
+ "replyMarkdown": reply_markdown or "", "error": error or ""}
190
+ if dispatch_attempt:
191
+ frame["dispatchAttempt"] = int(dispatch_attempt)
192
+ if session_id:
193
+ frame["sessionId"] = session_id
194
+ if usage:
195
+ frame["usage"] = {"provider": "claude", "model": model or "", **usage}
196
+ return frame
197
+
198
+
199
+ def register_mcp_server(cli_binary: str, workdir: Path, env: dict, url: str, mcp_token: str) -> bool:
200
+ """以 local scope 注册 ainative MCP(用户添加即受信,headless 免批准)。
201
+
202
+ token 每回合轮换(turn-bound),-f 覆盖旧注册。失败仅告警:工具不可用时数字员工
203
+ 仍可凭 capability 包内上下文作答。
204
+ """
205
+ # 该 CLI 版本无 force 旗标:先 remove(不存在则报错,忽略)再 add 实现每回合轮换覆盖
206
+ subprocess.run([cli_binary, "mcp", "remove", "--scope", "local", "ainative"],
207
+ cwd=str(workdir), env=env, capture_output=True, text=True, timeout=60)
208
+ argv = [cli_binary, "mcp", "add", "--transport", "http", "--scope", "local",
209
+ "ainative", url, "--header", f"Authorization: Bearer {mcp_token}"]
210
+ try:
211
+ proc = subprocess.run(argv, cwd=str(workdir), env=env, capture_output=True,
212
+ text=True, timeout=60)
213
+ if proc.returncode != 0:
214
+ log.warn("conversation.mcp_add_failed", code=proc.returncode,
215
+ stderr=(proc.stderr or "")[-200:])
216
+ return False
217
+ return True
218
+ except (OSError, subprocess.TimeoutExpired) as e:
219
+ log.warn("conversation.mcp_add_error", error=str(e))
220
+ return False