codebee 0.1.99 → 0.1.100

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.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import os
8
+ import re
8
9
  import stat
9
10
  import time
10
11
  from pathlib import Path
@@ -95,6 +96,8 @@ def list_entries(workdir, *, now=None):
95
96
  raw = row.get("content") or ""
96
97
  row["verified"] = hashlib.sha256(raw.encode("utf-8")).hexdigest() == row.get("content_sha256")
97
98
  row["expired"] = float(row.get("expires_at") or 0) <= stamp
99
+ row["feedback_score"] = int(row.get("feedback_score") or 0)
100
+ row["feedback_count"] = int(row.get("feedback_count") or 0)
98
101
  return sorted(rows, key=lambda x: (x.get("created_at", 0), x.get("id", "")), reverse=True)
99
102
 
100
103
 
@@ -126,14 +129,78 @@ def decide(workdir, entry_id, action, *, expected_version=None, now=None):
126
129
  return row
127
130
 
128
131
 
129
- def active_text(workdir, *, cap=4000, now=None):
132
+ def feedback(workdir, entry_id, *, useful, expected_version=None, now=None):
133
+ """Record explicit retrieval feedback as a versioned append-only update."""
134
+ if not isinstance(useful, bool):
135
+ raise ValueError("useful 必须是布尔值")
136
+ path = _file(workdir)
137
+ row = _read(path).get(str(entry_id or ""))
138
+ if not row:
139
+ raise KeyError("记忆条目不存在")
140
+ if expected_version is not None and int(expected_version) != int(row.get("version") or 1):
141
+ raise ValueError("记忆条目版本已变化,请刷新后重试")
142
+ raw = row.get("content") or ""
143
+ if hashlib.sha256(raw.encode("utf-8")).hexdigest() != row.get("content_sha256"):
144
+ raise ValueError("记忆内容指纹校验失败")
145
+ if row.get("status") != "approved":
146
+ raise ValueError("只有已批准的记忆条目可以反馈")
130
147
  stamp = float(now if now is not None else time.time())
148
+ row = dict(row, feedback_score=int(row.get("feedback_score") or 0) +
149
+ (1 if useful else -1),
150
+ feedback_count=int(row.get("feedback_count") or 0) + 1,
151
+ feedback_at=stamp, version=int(row.get("version") or 1) + 1,
152
+ updated_at=stamp)
153
+ _append(path, row)
154
+ return row
155
+
156
+
157
+ def _query_terms(query):
158
+ query = str(query or "").strip().lower()
159
+ words = re.findall(r"[a-z0-9_]+|[\u3400-\u9fff]+", query)
160
+ terms = set()
161
+ for word in words:
162
+ if len(word) > 2 and re.fullmatch(r"[\u3400-\u9fff]+", word):
163
+ terms.update(word[i:i + 2] for i in range(len(word) - 1))
164
+ else:
165
+ terms.add(word)
166
+ return terms
167
+
168
+
169
+ def _relevance(row, terms):
170
+ if not terms:
171
+ return 0
172
+ title = str(row.get("title") or "").lower()
173
+ content = str(row.get("content") or "").lower()
174
+ title_terms = _query_terms(title)
175
+ content_terms = _query_terms(content)
176
+ return 4 * len(terms & title_terms) + len(terms & content_terms)
177
+
178
+
179
+ def active_text(workdir, *, cap=4000, query="", now=None):
180
+ stamp = float(now if now is not None else time.time())
181
+ try:
182
+ cap = max(0, int(cap))
183
+ except (TypeError, ValueError):
184
+ cap = 4000
185
+ heading = "## 已批准项目记忆(内容已校验,过期条目不注入)\n\n"
186
+ cap = max(0, cap - len(heading) - 2)
187
+ terms = _query_terms(query)
131
188
  items = []
132
189
  for row in list_entries(workdir, now=stamp):
133
190
  if row.get("status") != "approved" or row.get("expired") or not row.get("verified"):
134
191
  continue
135
- items.append("### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or ""))
136
- text = "\n\n".join(items)
137
- if len(text) > cap:
138
- text = text[-cap:]
139
- return ("## 已批准项目记忆(内容已校验,过期条目不注入)\n\n" + text + "\n\n") if text else ""
192
+ block = "### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or "")
193
+ items.append((row, block))
194
+ items.sort(key=lambda item: (_relevance(item[0], terms),
195
+ item[0].get("feedback_score", 0),
196
+ item[0].get("created_at", 0)), reverse=True)
197
+ selected = []
198
+ used = 0
199
+ for _row, block in items:
200
+ cost = len(block) + (2 if selected else 0)
201
+ if used + cost > cap:
202
+ continue
203
+ selected.append(block)
204
+ used += cost
205
+ text = "\n\n".join(selected)
206
+ return (heading + text + "\n\n") if text else ""
@@ -88,6 +88,12 @@ def effective_agents(catalog_entries, detected):
88
88
  if not pref.get("enabled", entry.get("default_enabled", False)):
89
89
  continue
90
90
  out.append(_build_agent(entry))
91
+ try:
92
+ from . import backend_profiles
93
+ out.extend(backend_profiles.runtime_agents())
94
+ except Exception:
95
+ # Broken local profile storage must not disable unrelated installed agents.
96
+ pass
91
97
  out.extend([dict(m) for m in MOCK_AGENTS])
92
98
  return out
93
99
 
@@ -98,6 +104,13 @@ def installed_agent(entry_id, catalog_entries, detected):
98
104
  续会话是用户对该 CLI 的显式指定,不依赖其是否参与自动路由;
99
105
  未安装 / 无编排配置 / id 不存在时返回 None。
100
106
  """
107
+ if str(entry_id or "").startswith("acp-"):
108
+ try:
109
+ from . import backend_profiles
110
+ return next((agent for agent in backend_profiles.runtime_agents()
111
+ if agent.get("id") == entry_id), None)
112
+ except Exception:
113
+ return None
101
114
  for entry in catalog_entries:
102
115
  if entry.get("id") != entry_id:
103
116
  continue
@@ -1678,7 +1678,8 @@ def _opencode_fast_banner_death(res):
1678
1678
 
1679
1679
  def run_agent(agent, prompt, workdir=None, readonly=True,
1680
1680
  timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
1681
- images=None, require_tools=False, deadline=None):
1681
+ images=None, require_tools=False, deadline=None, command_context=None,
1682
+ checkpoint_run_id=""):
1682
1683
  """执行一次智能体调用,返回统一结构
1683
1684
  {ok, text, json, cost_usd, tokens, error, error_code, raw}。
1684
1685
  agent 来自 registry.effective_agents();resume 为已有会话 id,仅真实智能体生效
@@ -1722,6 +1723,17 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1722
1723
  "kind": agent.get("kind", "generic"), "model": None,
1723
1724
  "attempts": []}
1724
1725
  kind = agent.get("kind", "generic")
1726
+ if kind == "acp":
1727
+ from . import acp_client
1728
+ context = dict(command_context or {})
1729
+ context.setdefault("run_id", checkpoint_run_id)
1730
+ context.setdefault("sandbox", {})
1731
+ context.setdefault("allow_terminal", not readonly)
1732
+ return acp_client.run_agent(
1733
+ agent, prompt, workdir=workdir, readonly=readonly, timeout=timeout,
1734
+ cancel_event=cancel_event, deadline=deadline,
1735
+ command_context=context, checkpoint_run_id=checkpoint_run_id,
1736
+ resume=resume)
1725
1737
  if agent.get("runtime_config_sync_failed"):
1726
1738
  return {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
1727
1739
  "tokens": 0, "usage": None,
package/app/main.py CHANGED
@@ -358,6 +358,15 @@ class Handler(BaseHTTPRequestHandler):
358
358
  return self._json(200, {"entries": project_memory.list_entries(task.get("workdir"))})
359
359
  except (OSError, ValueError) as exc:
360
360
  return self._json(409, {"error": str(exc)})
361
+ if path == "/api/backend-profiles":
362
+ from core import backend_profiles
363
+ try:
364
+ return self._json(200, {"profiles": backend_profiles.list_profiles()})
365
+ except (OSError, ValueError) as exc:
366
+ return self._json(409, {"error": str(exc)})
367
+ if path == "/api/command-auth":
368
+ from core import command_auth
369
+ return self._json(200, command_auth.view())
361
370
  if path == "/api/agents/presence":
362
371
  from core import presence
363
372
  return self._json(200, {"agents": presence.list_agents()})
@@ -887,6 +896,58 @@ class Handler(BaseHTTPRequestHandler):
887
896
  return deny
888
897
  if path == "/api/tasks":
889
898
  return self._api_create_task()
899
+ if path == "/api/backend-profiles":
900
+ from core import backend_profiles
901
+ try:
902
+ return self._json(200, {"profile": backend_profiles.save(self._body() or {})})
903
+ except KeyError as exc:
904
+ return self._json(404, {"error": str(exc)})
905
+ except (OSError, TypeError, ValueError) as exc:
906
+ return self._json(400, {"error": str(exc)})
907
+ m = re.match(r"^/api/backend-profiles/([^/]+)/delete$", path)
908
+ if m:
909
+ from core import backend_profiles
910
+ try:
911
+ backend_profiles.remove(unquote(m.group(1)))
912
+ store.bump_state()
913
+ return self._json(200, {"ok": True})
914
+ except KeyError as exc:
915
+ return self._json(404, {"error": str(exc)})
916
+ except (OSError, TypeError, ValueError) as exc:
917
+ return self._json(400, {"error": str(exc)})
918
+ if path == "/api/command-auth":
919
+ from core import command_auth
920
+ body = self._body() or {}
921
+ try:
922
+ if "mode" in body:
923
+ command_auth.set_mode(body.get("mode"))
924
+ return self._json(200, command_auth.view())
925
+ except (TypeError, ValueError) as exc:
926
+ return self._json(400, {"error": str(exc)})
927
+ m = re.match(r"^/api/command-auth/requests/([^/]+)$", path)
928
+ if m:
929
+ from core import command_auth
930
+ body = self._body() or {}
931
+ try:
932
+ result = command_auth.decide(
933
+ unquote(m.group(1)), approve=body.get("approve"),
934
+ scope=body.get("scope", "once"), match=body.get("match", "exact"),
935
+ prefix=body.get("prefix"))
936
+ return self._json(200, result)
937
+ except KeyError as exc:
938
+ return self._json(404, {"error": str(exc)})
939
+ except (TypeError, ValueError) as exc:
940
+ return self._json(400, {"error": str(exc)})
941
+ m = re.match(r"^/api/command-auth/rules/([^/]+)/revoke$", path)
942
+ if m:
943
+ from core import command_auth
944
+ try:
945
+ command_auth.revoke(unquote(m.group(1)))
946
+ return self._json(200, {"ok": True})
947
+ except KeyError as exc:
948
+ return self._json(404, {"error": str(exc)})
949
+ except (TypeError, ValueError) as exc:
950
+ return self._json(400, {"error": str(exc)})
890
951
  if path == "/api/a2a/tasks":
891
952
  status, resp = self._create_and_start(self._body() or {})
892
953
  return self._json(status, {"protocol": "a2a-lite", **resp})
@@ -915,6 +976,11 @@ class Handler(BaseHTTPRequestHandler):
915
976
  body = self._body() or {}
916
977
  from core import project_memory
917
978
  try:
979
+ if str(body.get("action") or "").lower() == "feedback":
980
+ row = project_memory.feedback(
981
+ task.get("workdir"), body.get("id"), useful=body.get("useful"),
982
+ expected_version=body.get("expected_version"))
983
+ return self._json(200, {"entry": row})
918
984
  row = project_memory.decide(task.get("workdir"), body.get("id"),
919
985
  body.get("action"),
920
986
  expected_version=body.get("expected_version"))
@@ -3480,6 +3546,10 @@ def main():
3480
3546
  print("[CodeBee] v%s" % (_ver or "?"), flush=True)
3481
3547
  _step("正在准备数据目录…")
3482
3548
  paths.ensure_dirs()
3549
+ from core import backend_profiles as _backend_profiles
3550
+ from core import command_auth as _command_auth
3551
+ _backend_profiles.init()
3552
+ _command_auth.init()
3483
3553
  from core import attachments
3484
3554
  n_pc = attachments.cleanup_stale() # 待提交附件残留清理(崩溃/弃单不堆积)
3485
3555
  if n_pc:
package/app/ui/app.js CHANGED
@@ -4764,7 +4764,8 @@ window.loadProjectMemory = async function (taskId) {
4764
4764
  box.innerHTML = entries.map((entry) => {
4765
4765
  const canApprove = entry.status === "pending" && !entry.expired && entry.verified;
4766
4766
  const canRestore = entry.status === "archived" && !entry.expired && entry.verified;
4767
- const actions = (canApprove ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'approve\',' + Number(entry.version) + ')">' + esc(t("批准")) + "</button><button type=\"button\" class=\"ghost small\" onclick=\"decideProjectMemory('" + jsq(taskId) + "','" + jsq(entry.id) + "','reject'," + Number(entry.version) + ')\">' + esc(t("拒绝")) + "</button>" : "") +
4767
+ const feedback = entry.status === "approved" ? '<button type="button" class="ghost small" onclick="feedbackProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',true,' + Number(entry.version) + ')">' + esc(t("有用")) + '</button><button type="button" class="ghost small" onclick="feedbackProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',false,' + Number(entry.version) + ')">' + esc(t("无用")) + '</button>' : "";
4768
+ const actions = feedback + (canApprove ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'approve\',' + Number(entry.version) + ')">' + esc(t("批准")) + "</button><button type=\"button\" class=\"ghost small\" onclick=\"decideProjectMemory('" + jsq(taskId) + "','" + jsq(entry.id) + "','reject'," + Number(entry.version) + ')">' + esc(t("拒绝")) + "</button>" : "") +
4768
4769
  (entry.status === "approved" ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'archive\',' + Number(entry.version) + ')">' + esc(t("归档")) + "</button>" : "") +
4769
4770
  (canRestore ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'restore\',' + Number(entry.version) + ')">' + esc(t("恢复")) + "</button>" : "");
4770
4771
  return '<article class="rd-memory-row"><div><strong>' + esc(entry.title || t("项目记忆")) + '</strong><span class="chip">' + esc(entry.status || "") + (entry.expired ? " · " + esc(t("已过期")) : "") + '</span></div><pre>' + esc(entry.content || "") + '</pre><div class="rd-memory-actions">' + actions + "</div></article>";
@@ -4782,6 +4783,16 @@ window.decideProjectMemory = async function (taskId, id, action, version) {
4782
4783
  } catch (error) { toast(t("项目记忆操作失败:") + error.message, true); }
4783
4784
  };
4784
4785
 
4786
+ window.feedbackProjectMemory = async function (taskId, id, useful, version) {
4787
+ try {
4788
+ await api("/api/tasks/" + encodeURIComponent(taskId) + "/memory", {
4789
+ method: "POST", headers: Object.assign({ "Content-Type": "application/json" }, authHeaders()),
4790
+ body: JSON.stringify({ id, action: "feedback", useful, expected_version: version }),
4791
+ });
4792
+ await loadProjectMemory(taskId);
4793
+ } catch (error) { toast(t("项目记忆反馈失败:") + error.message, true); }
4794
+ };
4795
+
4785
4796
  window.loadRunCheckpoints = async function (requestedRunId) {
4786
4797
  const runId = requestedRunId || S.detailRunId || ((S.state || {}).task_latest || {})[S.detailTaskKey]?.id;
4787
4798
  if (!runId) return;
@@ -13362,8 +13373,77 @@ async function loadSettings() {
13362
13373
  }
13363
13374
  } catch (e) { /* 忽略 */ }
13364
13375
  loadSettingsV2(); // 引擎调参卡独立拉取(挂了不影响基础设置)
13376
+ loadBackendProfiles();
13377
+ loadCommandAuth();
13365
13378
  }
13366
13379
 
13380
+ async function loadBackendProfiles() {
13381
+ const box = $("acp-profiles");
13382
+ if (!box) return;
13383
+ try {
13384
+ const data = await api("/api/backend-profiles");
13385
+ const rows = data.profiles || [];
13386
+ box.innerHTML = rows.length ? rows.map((row) =>
13387
+ '<div class="list-row"><span><b>' + esc(row.label || row.id) + '</b> · ' + esc(row.command || "") +
13388
+ (row.enabled === false ? ' · ' + esc(t("已停用")) : '') + '</span><button class="ghost small" type="button" onclick="deleteAcpProfile(\'' + jsq(row.id) + '\')">' + esc(t("删除")) + '</button></div>'
13389
+ ).join("") : '<span class="hint">' + esc(t("暂无 ACP 后端")) + '</span>';
13390
+ } catch (error) {
13391
+ box.innerHTML = '<span class="hint">' + esc(t("ACP 配置加载失败:")) + esc(error.message) + '</span>';
13392
+ }
13393
+ }
13394
+
13395
+ window.saveAcpProfile = async function () {
13396
+ const msg = $("acp-msg");
13397
+ try {
13398
+ const argsText = ($("acp-args") || {}).value || "[]";
13399
+ const args = JSON.parse(argsText);
13400
+ if (!Array.isArray(args)) throw new Error(t("参数必须是 JSON 数组"));
13401
+ await api("/api/backend-profiles", { method: "POST", headers: Object.assign({ "Content-Type": "application/json" }, authHeaders()),
13402
+ body: JSON.stringify({ label: $("acp-label").value, command: $("acp-command").value,
13403
+ args, workspace_path: $("acp-workspace").value }) });
13404
+ if (msg) msg.textContent = t("ACP 后端已保存");
13405
+ ["acp-label", "acp-command", "acp-args", "acp-workspace"].forEach((id) => { const el = $(id); if (el) el.value = id === "acp-args" ? "[]" : ""; });
13406
+ await loadBackendProfiles();
13407
+ await refreshState();
13408
+ } catch (error) { if (msg) msg.textContent = t("ACP 保存失败:") + error.message; }
13409
+ };
13410
+
13411
+ window.deleteAcpProfile = async function (id) {
13412
+ try {
13413
+ await api("/api/backend-profiles/" + encodeURIComponent(id) + "/delete", { method: "POST", headers: authHeaders(), body: "{}" });
13414
+ await loadBackendProfiles();
13415
+ await refreshState();
13416
+ } catch (error) { toast(t("删除 ACP 失败:") + error.message, true); }
13417
+ };
13418
+
13419
+ async function loadCommandAuth() {
13420
+ const pendingBox = $("command-auth-pending");
13421
+ const mode = $("command-auth-mode");
13422
+ if (!pendingBox || !mode) return;
13423
+ try {
13424
+ const data = await api("/api/command-auth");
13425
+ mode.value = data.mode || "legacy";
13426
+ const rows = data.pending || [];
13427
+ pendingBox.innerHTML = rows.length ? rows.map((row) =>
13428
+ '<div class="list-row"><span><code>' + esc(row.command || "") + '</code> · ' + esc(row.source || "") + '</span><button class="ghost small" type="button" onclick="decideCommandAuth(\'' + jsq(row.id) + '\',true)">' + esc(t("批准一次")) + '</button><button class="ghost small" type="button" onclick="decideCommandAuth(\'' + jsq(row.id) + '\',false)">' + esc(t("拒绝")) + '</button></div>'
13429
+ ).join("") : '<span class="hint">' + esc(t("暂无待批准命令")) + '</span>';
13430
+ } catch (error) { pendingBox.innerHTML = '<span class="hint">' + esc(t("授权状态加载失败:")) + esc(error.message) + '</span>'; }
13431
+ }
13432
+
13433
+ window.saveCommandAuthMode = async function () {
13434
+ try {
13435
+ await api("/api/command-auth", { method: "POST", headers: Object.assign({ "Content-Type": "application/json" }, authHeaders()), body: JSON.stringify({ mode: $("command-auth-mode").value }) });
13436
+ await loadCommandAuth();
13437
+ } catch (error) { const msg = $("command-auth-msg"); if (msg) msg.textContent = t("授权策略保存失败:") + error.message; }
13438
+ };
13439
+
13440
+ window.decideCommandAuth = async function (id, approve) {
13441
+ try {
13442
+ await api("/api/command-auth/requests/" + encodeURIComponent(id), { method: "POST", headers: Object.assign({ "Content-Type": "application/json" }, authHeaders()), body: JSON.stringify({ approve, scope: "once" }) });
13443
+ await loadCommandAuth();
13444
+ } catch (error) { const msg = $("command-auth-msg"); if (msg) msg.textContent = t("命令授权操作失败:") + error.message; }
13445
+ };
13446
+
13367
13447
  /* ---------------- settings_v2 引擎调参卡(schema 驱动) ----------------
13368
13448
  * /api/settings-v2 拉 describe(含字段元数据),按 namespace 渲染控件;
13369
13449
  * 保存走 /api/settings-v2/<ns> mutate(expected_revision CAS,409=别处已改,
package/app/ui/index.html CHANGED
@@ -1076,6 +1076,25 @@
1076
1076
  </div>
1077
1077
  <p data-i18n="内置智能体(直连对话/直接执行)会把服务器的工具并入工具循环(mcp__服务器__工具 名)。命令在你本机执行,与「执行命令」工具同一自主权级别;工具清单缓存 5 分钟。留空 = 不启用。" class="hint">内置智能体(直连对话/直接执行)会把服务器的工具并入工具循环(mcp__服务器__工具 名)。命令在你本机执行,与「执行命令」工具同一自主权级别;工具清单缓存 5 分钟。留空 = 不启用。</p>
1078
1078
  </div>
1079
+ <h3 class="sec-title">ACP Backend Profiles</h3>
1080
+ <p class="hint">接入遵循 Agent Client Protocol v1 的本地或 SSH stdio Agent。密钥只保存在本机,读取时始终脱敏。</p>
1081
+ <div class="field">
1082
+ <div id="acp-profiles" class="list"></div>
1083
+ <div class="input-row" style="margin-top:6px">
1084
+ <input id="acp-label" type="text" placeholder="名称">
1085
+ <input id="acp-command" type="text" placeholder="启动命令,例如 acp-agent">
1086
+ <input id="acp-args" type="text" placeholder="参数 JSON 数组,例如 [\"--stdio\"]">
1087
+ <input id="acp-workspace" type="text" placeholder="Agent 工作区(可空)">
1088
+ <button class="ghost small" type="button" onclick="saveAcpProfile()">保存 ACP</button>
1089
+ </div>
1090
+ <p id="acp-msg" class="hint"></p>
1091
+ </div>
1092
+ <h3 class="sec-title">命令授权</h3>
1093
+ <div class="field">
1094
+ <div class="input-row"><select id="command-auth-mode"><option value="legacy">legacy:自动执行</option><option value="restricted">restricted:逐条等待批准</option></select><button class="ghost small" type="button" onclick="saveCommandAuthMode()">保存策略</button></div>
1095
+ <div id="command-auth-pending" class="list"></div>
1096
+ <p id="command-auth-msg" class="hint"></p>
1097
+ </div>
1079
1098
  <h3 data-i18n="推送通知" class="sec-title">推送通知</h3>
1080
1099
  <div class="field"><label data-i18n="群机器人 Webhook(钉钉 / 飞书 / 企业微信,按域名自动适配)">群机器人 Webhook(钉钉 / 飞书 / 企业微信,按域名自动适配)</label>
1081
1100
  <div class="input-row">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.99",
3
+ "version": "0.1.100",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {