codebee 0.1.4 → 0.1.6
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/CHANGELOG.md +44 -0
- package/README.md +8 -0
- package/app/core/attachments.py +40 -0
- package/app/core/bookmeta.py +157 -52
- package/app/core/bookmeta_catalog.py +67 -90
- package/app/core/builtin_agent.py +190 -8
- package/app/core/flows.py +328 -328
- package/app/core/gitmod.py +82 -8
- package/app/core/health.py +55 -1
- package/app/core/history.py +8 -2
- package/app/core/jobs.py +19 -4
- package/app/core/manager.py +159 -1
- package/app/core/modelhub.py +148 -15
- package/app/core/pipeline.py +2392 -2267
- package/app/core/registry.py +4 -0
- package/app/core/router.py +13 -7
- package/app/core/runner.py +194 -21
- package/app/core/selfupdate.py +60 -11
- package/app/core/store.py +4 -2
- package/app/main.py +101 -7
- package/app/ui/app.js +447 -115
- package/app/ui/i18n.js +32 -10
- package/app/ui/icons/bee.svg +79 -0
- package/app/ui/index.html +8 -3
- package/app/ui/style.css +3226 -2764
- package/package.json +2 -1
package/app/core/gitmod.py
CHANGED
|
@@ -66,6 +66,11 @@ def repo_info(workdir):
|
|
|
66
66
|
if h.strip():
|
|
67
67
|
recent.append({"hash": h.strip(), "subject": subj.strip()[:120]})
|
|
68
68
|
br = (branch["stdout"] or "").strip() if branch["ok"] else ""
|
|
69
|
+
if br == "HEAD":
|
|
70
|
+
# 游离 HEAD 时 abbrev-ref 返回字面量 "HEAD"——统一成哨兵值。
|
|
71
|
+
# 否则「切回 from_branch」会把 "HEAD" 当分支名原样 checkout,
|
|
72
|
+
# 而 git checkout HEAD 是原地空转(退出码 0 但不换位),分支永远切不走。
|
|
73
|
+
br = "(游离 HEAD)"
|
|
69
74
|
return {
|
|
70
75
|
"repo": True,
|
|
71
76
|
"branch": br or "(游离 HEAD)",
|
|
@@ -408,7 +413,7 @@ def finalize_run(workdir, gitinfo, message):
|
|
|
408
413
|
# 切回原分支;原为游离 HEAD 时回到基线提交(同样游离)
|
|
409
414
|
back = (gitinfo or {}).get("from_branch") or ""
|
|
410
415
|
base = (gitinfo or {}).get("base_commit") or ""
|
|
411
|
-
target = back if (back and back
|
|
416
|
+
target = back if (back and back not in ("(游离 HEAD)", "HEAD") and back != branch) else base
|
|
412
417
|
if target:
|
|
413
418
|
co = _git(workdir, "checkout", "--quiet", target, timeout=60)
|
|
414
419
|
if not co["ok"]:
|
|
@@ -472,6 +477,12 @@ def merge_task_branch(workdir, task):
|
|
|
472
477
|
out = {"restore_error": ""}
|
|
473
478
|
_restore_stash(wd, stash_sha, out)
|
|
474
479
|
return refuse("无法确定任务分支的基线分支(找不到带检出信息的运行记录),请手工合并")
|
|
480
|
+
if origin in ("HEAD", "(游离 HEAD)"):
|
|
481
|
+
# 老数据里游离基线存的是字面量 "HEAD":合并没有分支可落,显式拒绝
|
|
482
|
+
if stash_sha:
|
|
483
|
+
out = {"restore_error": ""}
|
|
484
|
+
_restore_stash(wd, stash_sha, out)
|
|
485
|
+
return refuse("任务分支从游离 HEAD 检出,没有可合并的基线分支;请手工合并")
|
|
475
486
|
if cur != origin:
|
|
476
487
|
if stash_sha:
|
|
477
488
|
out = {"restore_error": ""}
|
|
@@ -516,11 +527,56 @@ def merge_task_branch(workdir, task):
|
|
|
516
527
|
return True, "", info_out
|
|
517
528
|
|
|
518
529
|
|
|
530
|
+
def _worktrees_on_branch(workdir, br):
|
|
531
|
+
"""git worktree list --porcelain → 检出 br 的工作树路径列表。
|
|
532
|
+
|
|
533
|
+
porcelain 自 git 2.7 起可用;解析失败返回 None(调用方显式拒绝,
|
|
534
|
+
绝不带着「分支可能仍被占用」硬删)。
|
|
535
|
+
"""
|
|
536
|
+
w = _git(workdir, "worktree", "list", "--porcelain")
|
|
537
|
+
if not w["ok"]:
|
|
538
|
+
return None
|
|
539
|
+
entries, cur = [], None
|
|
540
|
+
for ln in (w["stdout"] or "").splitlines():
|
|
541
|
+
if ln.startswith("worktree "):
|
|
542
|
+
cur = {"path": ln[len("worktree "):].strip(), "branch": ""}
|
|
543
|
+
entries.append(cur)
|
|
544
|
+
elif cur is not None and ln.startswith("branch refs/heads/"):
|
|
545
|
+
cur["branch"] = ln[len("branch refs/heads/"):].strip()
|
|
546
|
+
return [x for x in entries if x["branch"] == br]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _switch_off_branch(workdir, br, target):
|
|
550
|
+
"""把工作树从 br 上切走,返回错误信息(成功为空串)。
|
|
551
|
+
|
|
552
|
+
先尝试切到 target;两种失败形态都兜底成游离:
|
|
553
|
+
- checkout 直接失败(如目标分支被别的工作树占用)→ --detach target;
|
|
554
|
+
- checkout「成功」却没换位(target 是 HEAD 这类原地引用,实测
|
|
555
|
+
git checkout HEAD 退出码 0 且停在原分支)→ 复查后强制游离。
|
|
556
|
+
最终以 rev-parse 复核 br 真的腾空为准,不信 checkout 的退出码。
|
|
557
|
+
"""
|
|
558
|
+
co = _git(workdir, "checkout", "--quiet", target, timeout=60) if target \
|
|
559
|
+
else _git(workdir, "checkout", "--quiet", "--detach", timeout=60)
|
|
560
|
+
cur = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
561
|
+
if (cur["stdout"] or "").strip() == br:
|
|
562
|
+
co = _git(workdir, "checkout", "--quiet", "--detach", target, timeout=60) \
|
|
563
|
+
if target else _git(workdir, "checkout", "--quiet", "--detach", timeout=60)
|
|
564
|
+
if not co["ok"]:
|
|
565
|
+
return "切离任务分支失败:%s" % (co["stderr"] or "")[-120:]
|
|
566
|
+
elif not co["ok"]:
|
|
567
|
+
return "切回 %s 失败:%s" % (target, (co["stderr"] or "")[-120:])
|
|
568
|
+
return ""
|
|
569
|
+
|
|
570
|
+
|
|
519
571
|
def discard_task_branch(workdir, task):
|
|
520
572
|
"""丢弃任务分支(人审后的「否决」出口,不可恢复)。
|
|
521
573
|
|
|
522
574
|
守卫同 merge(活跃任务/仓库缺失/分支不存在拒绝);若工作区恰好停在
|
|
523
575
|
任务分支上且干净(收尾出错的遗留现场),先切回基线再删分支。
|
|
576
|
+
分支也可能被**其它** worktree 检出(run 收尾被中断、任务分支滞留在
|
|
577
|
+
另一个工作树里)——git 拒绝删除被检出的分支,所以删前逐个清理:
|
|
578
|
+
干净的工作树切回基线(目标分支被占用就游离在那),有未提交改动的
|
|
579
|
+
不动、显式拒绝;目录已消失的残留登记由 worktree prune 清掉。
|
|
524
580
|
返回 (ok, 错误信息)。
|
|
525
581
|
"""
|
|
526
582
|
def refuse(msg):
|
|
@@ -536,19 +592,37 @@ def discard_task_branch(workdir, task):
|
|
|
536
592
|
br = branch_name(tid)
|
|
537
593
|
if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
|
|
538
594
|
return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
|
|
595
|
+
latest = _latest_run_git(tid) or {}
|
|
596
|
+
back = latest.get("from_branch") or ""
|
|
597
|
+
base = latest.get("base_commit") or ""
|
|
598
|
+
target = back if (back and back not in ("(游离 HEAD)", "HEAD") and back != br) else base
|
|
539
599
|
cur = info.get("branch") or ""
|
|
540
600
|
if cur == br:
|
|
541
601
|
if info.get("dirty"):
|
|
542
602
|
return refuse("工作区停在任务分支上且有未提交改动,请先处理再丢弃")
|
|
543
|
-
latest = _latest_run_git(tid) or {}
|
|
544
|
-
back = latest.get("from_branch") or ""
|
|
545
|
-
base = latest.get("base_commit") or ""
|
|
546
|
-
target = back if (back and back != "(游离 HEAD)" and back != br) else base
|
|
547
603
|
if not target:
|
|
548
604
|
return refuse("工作区停在任务分支上且找不到可切回的基线,请手工切走后再丢弃")
|
|
549
|
-
|
|
550
|
-
if
|
|
551
|
-
return refuse(
|
|
605
|
+
err = _switch_off_branch(wd, br, target)
|
|
606
|
+
if err:
|
|
607
|
+
return refuse(err)
|
|
608
|
+
wts = _worktrees_on_branch(wd, br)
|
|
609
|
+
if wts is None:
|
|
610
|
+
return refuse("无法枚举 worktree,请手工处理占用任务分支的工作树后再丢弃")
|
|
611
|
+
for wt in wts:
|
|
612
|
+
p = wt["path"]
|
|
613
|
+
if not os.path.isdir(p):
|
|
614
|
+
continue
|
|
615
|
+
st = _git(p, "status", "--porcelain")
|
|
616
|
+
dirty = [ln for ln in (st["stdout"] or "").splitlines()
|
|
617
|
+
if ln.strip() and not _attach_entry(ln.strip())] if st["ok"] else []
|
|
618
|
+
if dirty:
|
|
619
|
+
return refuse("任务分支仍被工作树 %s 占用且其中有未提交改动,"
|
|
620
|
+
"请先处理后再丢弃" % p)
|
|
621
|
+
err = _switch_off_branch(p, br, target)
|
|
622
|
+
if err:
|
|
623
|
+
return refuse("工作树 %s %s" % (p, err))
|
|
624
|
+
if wts:
|
|
625
|
+
_git(wd, "worktree", "prune")
|
|
552
626
|
d = _git(wd, "branch", "-D", br)
|
|
553
627
|
if not d["ok"]:
|
|
554
628
|
return refuse("删除任务分支失败:%s" % (d["stderr"] or "")[-160:])
|
package/app/core/health.py
CHANGED
|
@@ -157,6 +157,58 @@ def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
|
157
157
|
_persist()
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
# ------------------------------------------------- 静态死链告警(绑定层)
|
|
161
|
+
|
|
162
|
+
def report_binding_dead(key: str, error: str = ""):
|
|
163
|
+
"""静态死链告警:某 CLI 的绑定链在起跑前就已全部失效(厂商停用/删除/无密钥、
|
|
164
|
+
模型停用、协议不匹配)。与运行期 report_failure 的区别:不计数、不进探针,
|
|
165
|
+
立即置 down+告警(尊重静默);绑定恢复后由 report_binding_ok 自动解除。
|
|
166
|
+
伪供应商名带「绑定链·」前缀,不会与真实厂商名相撞。"""
|
|
167
|
+
if not key:
|
|
168
|
+
return
|
|
169
|
+
with _LOCK:
|
|
170
|
+
name = "绑定链·%s" % key
|
|
171
|
+
st = _PROVIDERS.get(name)
|
|
172
|
+
if st is None:
|
|
173
|
+
st = _PROVIDERS.setdefault(name, {
|
|
174
|
+
"name": name, "provider_id": "", "model": "",
|
|
175
|
+
"status": "ok", "consecutive_failures": 0,
|
|
176
|
+
"first_fail_at": 0, "last_fail_at": 0, "last_ok_at": 0,
|
|
177
|
+
"last_error": "", "alerted": False, "silenced": False,
|
|
178
|
+
"silence_until": 0, "probe_next_at": 0, "probe_backoff_idx": 0,
|
|
179
|
+
"recovered_at": 0,
|
|
180
|
+
})
|
|
181
|
+
st["static"] = True
|
|
182
|
+
st["binding_key"] = str(key)
|
|
183
|
+
st["name"] = name
|
|
184
|
+
st["status"] = "down"
|
|
185
|
+
st["last_fail_at"] = _now()
|
|
186
|
+
st["last_error"] = str(error or "")[:300]
|
|
187
|
+
st["probe_next_at"] = 0
|
|
188
|
+
if not _effective_silenced(st) and not st.get("alerted"):
|
|
189
|
+
st["alerted"] = True
|
|
190
|
+
log.warning("[health] ⚠️ 绑定链告警:%s 全部失效(%s)",
|
|
191
|
+
name, st["last_error"][:120])
|
|
192
|
+
_persist()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def report_binding_ok(key: str):
|
|
196
|
+
"""绑定链恢复可用:自动解除该 CLI 的静态死链告警(无记录时零开销)。"""
|
|
197
|
+
if not key:
|
|
198
|
+
return
|
|
199
|
+
with _LOCK:
|
|
200
|
+
st = _PROVIDERS.get("绑定链·%s" % key)
|
|
201
|
+
if not st or not st.get("static"):
|
|
202
|
+
return
|
|
203
|
+
st["status"] = "recovered"
|
|
204
|
+
st["alerted"] = False
|
|
205
|
+
st["silenced"] = False
|
|
206
|
+
st["silence_until"] = 0
|
|
207
|
+
st["recovered_at"] = _now()
|
|
208
|
+
log.info("[health] 绑定链 %s 已恢复(静态告警解除)", st["name"])
|
|
209
|
+
_persist()
|
|
210
|
+
|
|
211
|
+
|
|
160
212
|
# ---------------------------------------------------------------- 手动操作
|
|
161
213
|
|
|
162
214
|
def silence(provider: str, minutes: int = 0):
|
|
@@ -215,6 +267,7 @@ def snapshot():
|
|
|
215
267
|
"last_error": st.get("last_error") or "",
|
|
216
268
|
"alerting": alerting,
|
|
217
269
|
"silenced": silenced,
|
|
270
|
+
"static": bool(st.get("static")),
|
|
218
271
|
})
|
|
219
272
|
# 稳定排序:告警中 > 故障中 > 恢复 > 正常
|
|
220
273
|
rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
|
|
@@ -271,7 +324,8 @@ def _probe_loop():
|
|
|
271
324
|
try:
|
|
272
325
|
with _LOCK:
|
|
273
326
|
targets = [(name, st) for name, st in _PROVIDERS.items()
|
|
274
|
-
if st.get("
|
|
327
|
+
if not st.get("static") # 静态死链无端点可探,绑定恢复时自动解除
|
|
328
|
+
and st.get("status") in ("failing", "down")
|
|
275
329
|
and (st.get("probe_next_at") or 0) <= _now()]
|
|
276
330
|
for name, st in targets:
|
|
277
331
|
idx = int(st.get("probe_backoff_idx") or 0)
|
package/app/core/history.py
CHANGED
|
@@ -18,14 +18,20 @@ def agent_stats(limit=200):
|
|
|
18
18
|
s["wins"] += 1
|
|
19
19
|
|
|
20
20
|
for run in store.list_runs(limit):
|
|
21
|
-
if run.get("kind") != "orchestration"
|
|
21
|
+
if run.get("kind") != "orchestration":
|
|
22
|
+
continue
|
|
23
|
+
# 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
|
|
24
|
+
# 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
|
|
25
|
+
# 选中。cancelled 不算(用户取消非智能体之过)。
|
|
26
|
+
if run.get("status") not in ("done", "failed"):
|
|
22
27
|
continue
|
|
23
28
|
verdict = run.get("verdict") or {}
|
|
24
29
|
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
25
30
|
ttype = (task or {}).get("type") or verdict.get("type")
|
|
26
31
|
if not ttype:
|
|
27
32
|
continue
|
|
28
|
-
win =
|
|
33
|
+
win = run.get("status") == "done" and bool(
|
|
34
|
+
verdict.get("pass") or verdict.get("publishable"))
|
|
29
35
|
for s in run.get("steps") or []:
|
|
30
36
|
if s.get("role") in ("implement", "draft"):
|
|
31
37
|
bump(s.get("agent"), ttype, win)
|
package/app/core/jobs.py
CHANGED
|
@@ -134,7 +134,9 @@ def cancel_event_for(run_id):
|
|
|
134
134
|
return ev
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
AUTO_RESUME_MAX =
|
|
137
|
+
AUTO_RESUME_MAX = 3 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
|
|
138
|
+
AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
|
|
139
|
+
# 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
def _maybe_auto_resume(run_id):
|
|
@@ -160,7 +162,13 @@ def _maybe_auto_resume(run_id):
|
|
|
160
162
|
return False
|
|
161
163
|
store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
|
|
162
164
|
auto_resumed_from=run_id)
|
|
163
|
-
|
|
165
|
+
|
|
166
|
+
def _enqueue():
|
|
167
|
+
_QUEUE.put({"kind": "orchestration",
|
|
168
|
+
"run_id": new_run["id"], "task_id": task["id"]})
|
|
169
|
+
t = threading.Timer(AUTO_RESUME_DELAY_S, _enqueue)
|
|
170
|
+
t.daemon = True
|
|
171
|
+
t.start()
|
|
164
172
|
return True
|
|
165
173
|
except Exception:
|
|
166
174
|
return False
|
|
@@ -205,6 +213,9 @@ def resume_interrupted(limit=3):
|
|
|
205
213
|
continue
|
|
206
214
|
if run.get("cancelled_by_user"):
|
|
207
215
|
continue
|
|
216
|
+
if run.get("paused"):
|
|
217
|
+
continue # 用户主动暂停的运行(重启收尸后 paused 标志保留)不自动续跑——
|
|
218
|
+
# 「继续」由用户点「继续任务」决定,不替用户做主
|
|
208
219
|
if not _recent(run):
|
|
209
220
|
continue
|
|
210
221
|
if latest_by_task.get(run["task_id"]) != run["id"]:
|
|
@@ -320,8 +331,12 @@ def _do_mgmt(job, ev):
|
|
|
320
331
|
store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
|
|
321
332
|
store.update_run(run_id, status="failed", error="未启用", ended_at=_now())
|
|
322
333
|
return
|
|
323
|
-
res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
|
|
324
|
-
|
|
334
|
+
res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
|
|
335
|
+
readonly=True,
|
|
336
|
+
# 300s:codex CLI 启动要拉 5 个 MCP 服务器 + 注入约 13 万
|
|
337
|
+
# token 技能上下文,首 token 常超 180s(2026-09-17 实测
|
|
338
|
+
# 网关裸探 8s 就回,慢在 CLI 自身启动与上下文)。
|
|
339
|
+
timeout=300, cancel_event=ev, log_path=str(log_abs))
|
|
325
340
|
ok = res["ok"] and "OK" in (res.get("text") or "").upper()
|
|
326
341
|
try:
|
|
327
342
|
_usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
|
package/app/core/manager.py
CHANGED
|
@@ -69,6 +69,55 @@ def detect_entry(entry):
|
|
|
69
69
|
return {"installed": False, "detail": ""}
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def sweep_orphan_cli_processes():
|
|
73
|
+
"""启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
|
|
74
|
+
/T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
|
|
75
|
+
`opencode run` 连上僵尸实例后 shell 全在僵尸的项目根里跑(2026-09-17
|
|
76
|
+
mo-so 实测:agent 在 Temp 里找代码,汇报「工作目录没有源码」)。
|
|
77
|
+
|
|
78
|
+
按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
|
|
79
|
+
opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
|
|
80
|
+
codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
|
|
81
|
+
kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
|
|
82
|
+
僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
|
|
83
|
+
claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
|
|
84
|
+
ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
|
|
85
|
+
"System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
86
|
+
if not os.path.isfile(ps_exe):
|
|
87
|
+
ps_exe = "powershell"
|
|
88
|
+
ps = ("Get-CimInstance Win32_Process | "
|
|
89
|
+
"Where-Object { $_.Name -match '^(opencode|codex|node|cmd)\\.exe$' } | "
|
|
90
|
+
"Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
|
|
91
|
+
try:
|
|
92
|
+
r = subprocess.run([ps_exe, "-NoProfile", "-Command", ps],
|
|
93
|
+
capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=60)
|
|
94
|
+
import json as _json
|
|
95
|
+
raw = r.stdout.decode("utf-8", "replace").strip()
|
|
96
|
+
items = _json.loads(raw) if raw else []
|
|
97
|
+
if isinstance(items, dict):
|
|
98
|
+
items = [items]
|
|
99
|
+
live = {i.get("ProcessId") for i in items}
|
|
100
|
+
killed = 0
|
|
101
|
+
for i in items:
|
|
102
|
+
cl = str(i.get("CommandLine") or "").lower()
|
|
103
|
+
ppid = i.get("ParentProcessId")
|
|
104
|
+
is_ours = (("opencode" in cl and "--model" in cl)
|
|
105
|
+
or ("codex" in cl and "--skip-git-repo-check" in cl)
|
|
106
|
+
or ("kimi-code" in cl and "main.mjs" in cl))
|
|
107
|
+
if not is_ours or ppid in live or not i.get("ProcessId"):
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
|
|
111
|
+
capture_output=True, creationflags=CREATE_NO_WINDOW,
|
|
112
|
+
timeout=15)
|
|
113
|
+
killed += 1
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
return killed
|
|
117
|
+
except Exception:
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
72
121
|
def detect_all(force=False):
|
|
73
122
|
"""检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
|
|
74
123
|
Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
|
|
@@ -786,6 +835,11 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
786
835
|
name = cp.get("name", "orch")
|
|
787
836
|
def q(v):
|
|
788
837
|
return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
|
|
838
|
+
if (cp.get("wire_api") or "responses") == "chat":
|
|
839
|
+
# codex 0.154+ 起 chat wire 被官方移除,写进 config.toml 会让 CLI 连配置
|
|
840
|
+
# 都载入不了(Error loading config.toml)——宁可明确拒绝也不落坏配置。
|
|
841
|
+
return ("供应商只有 chat completions wire,codex 0.154+ 已移除支持,未写入"
|
|
842
|
+
" config.toml——请为 codex 绑定 responses 兼容的供应商")
|
|
789
843
|
pairs = [("name", q(cp.get("name", name))),
|
|
790
844
|
("base_url", q(cp.get("base_url", ""))),
|
|
791
845
|
("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
|
|
@@ -1091,10 +1145,18 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1091
1145
|
全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
|
|
1092
1146
|
文本级就地 patch(保留注释)。返回错误串或 None。"""
|
|
1093
1147
|
npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
|
|
1148
|
+
base = prov.get("base_url") or ""
|
|
1149
|
+
if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
|
|
1150
|
+
# @ai-sdk/anthropic 在 baseURL 后只拼 /messages(官方默认 baseURL 本身带
|
|
1151
|
+
# /v1),而 models.json 里 anthropic 供应商的 base 不带 /v1(modelhub
|
|
1152
|
+
# 发请求时自己补)——不补会打到 <host>/messages,网关回 "Not Allowed"
|
|
1153
|
+
# (2026-09-17 公司Anthropic 实测)。
|
|
1154
|
+
base = base.rstrip("/") + "/v1"
|
|
1094
1155
|
block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
|
|
1095
|
-
"options": {"baseURL":
|
|
1156
|
+
"options": {"baseURL": base,
|
|
1096
1157
|
"apiKey": prov.get("api_key") or ""},
|
|
1097
1158
|
"models": {model: {"name": model}} if model else {}}
|
|
1159
|
+
top_perms = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
|
1098
1160
|
top_model = ("orch/" + model) if model else ""
|
|
1099
1161
|
targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
|
|
1100
1162
|
or [_opencode_config_candidates(entry)[0]]
|
|
@@ -1130,6 +1192,10 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1130
1192
|
data["provider"] = provs
|
|
1131
1193
|
if top_model:
|
|
1132
1194
|
data["model"] = top_model
|
|
1195
|
+
# 无人值守必配:headless 下 opencode 工具调用默认要审批,全部被
|
|
1196
|
+
# 拒("The user rejected permission...",2026-09-17 实测)——
|
|
1197
|
+
# 按用户拍板的「默认给全部权限」写入放行段
|
|
1198
|
+
data["permission"] = top_perms
|
|
1133
1199
|
new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
1134
1200
|
else: # JSONC(带注释):文本级 patch
|
|
1135
1201
|
block_json = json.dumps(block, ensure_ascii=False)
|
|
@@ -1140,6 +1206,8 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1140
1206
|
if top_model:
|
|
1141
1207
|
new_text, _ = _jsonc_set(new_text, ("model",),
|
|
1142
1208
|
json.dumps(top_model))
|
|
1209
|
+
new_text, _ = _jsonc_set(new_text, ("permission",),
|
|
1210
|
+
json.dumps(top_perms))
|
|
1143
1211
|
if os.path.isfile(path):
|
|
1144
1212
|
shutil.copyfile(path, path + ".bak")
|
|
1145
1213
|
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
@@ -1148,12 +1216,102 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1148
1216
|
return ";".join(errs) or None
|
|
1149
1217
|
|
|
1150
1218
|
|
|
1219
|
+
def _kimi_render(prov, model):
|
|
1220
|
+
"""渲染 kimi-code 的 CodeBee 托管块(顶层键在前,表在后——TOML 语义)。"""
|
|
1221
|
+
import re as _re
|
|
1222
|
+
ptype = "anthropic" if prov.get("protocol") == "anthropic" else "openai"
|
|
1223
|
+
base = prov.get("base_url") or ""
|
|
1224
|
+
alias = model or "default"
|
|
1225
|
+
pname = (prov.get("name") or "CodeBee").replace("\"", "")
|
|
1226
|
+
return (
|
|
1227
|
+
"# >>> CodeBee managed (do not edit between markers) >>>\n"
|
|
1228
|
+
"defaultProvider = \"orch\"\n"
|
|
1229
|
+
"defaultModel = \"%s\"\n"
|
|
1230
|
+
"yolo = true\n"
|
|
1231
|
+
"defaultPermissionMode = \"yolo\"\n"
|
|
1232
|
+
"\n"
|
|
1233
|
+
"[providers.orch]\n"
|
|
1234
|
+
"type = \"%s\"\n"
|
|
1235
|
+
"name = \"%s\"\n"
|
|
1236
|
+
"baseUrl = \"%s\"\n"
|
|
1237
|
+
"apiKey = \"%s\"\n"
|
|
1238
|
+
"\n"
|
|
1239
|
+
"[models.\"%s\"]\n"
|
|
1240
|
+
"provider = \"orch\"\n"
|
|
1241
|
+
"model = \"%s\"\n"
|
|
1242
|
+
"maxContextSize = 131072\n"
|
|
1243
|
+
"displayName = \"%s · %s\"\n"
|
|
1244
|
+
"# <<< CodeBee managed <<<\n"
|
|
1245
|
+
% (alias, ptype, pname, base,
|
|
1246
|
+
(prov.get("api_key") or "").replace("\"", ""),
|
|
1247
|
+
alias, alias, pname, alias))
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _sync_kimi_settings(entry, model, prov):
|
|
1251
|
+
"""kimi-code 专属:把绑定供应商写进 ~/.kimi-code/config.toml。
|
|
1252
|
+
|
|
1253
|
+
schema 从官方 bundle 反推(2026-09-17):顶层 defaultProvider/defaultModel/
|
|
1254
|
+
yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
|
|
1255
|
+
baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
|
|
1256
|
+
要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
|
|
1257
|
+
之间)幂等重写,用户自有内容保留在外。"""
|
|
1258
|
+
import re as _re
|
|
1259
|
+
top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
|
|
1260
|
+
targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
|
|
1261
|
+
errs = []
|
|
1262
|
+
for path in targets:
|
|
1263
|
+
try:
|
|
1264
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1265
|
+
text = ""
|
|
1266
|
+
if os.path.isfile(path):
|
|
1267
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
1268
|
+
shutil.copyfile(path, path + ".bak")
|
|
1269
|
+
# 移除旧托管块与旧顶层键(防重复/防键落进别的表)
|
|
1270
|
+
text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
|
|
1271
|
+
"", text, flags=_re.S)
|
|
1272
|
+
for k in top_keys:
|
|
1273
|
+
text = _re.sub(r"(?m)^%s\s*=.*$\n?" % k, "", text)
|
|
1274
|
+
body = _re.sub(r"\n+$", "\n", text).lstrip("\n")
|
|
1275
|
+
managed = _kimi_render(prov, model)
|
|
1276
|
+
has_table = _re.search(r"(?m)^\[", body) is not None
|
|
1277
|
+
top = "".join("%s\n" % ln for ln in managed.split("\n")
|
|
1278
|
+
if _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
|
|
1279
|
+
tables = "\n".join(ln for ln in managed.split("\n")
|
|
1280
|
+
if not _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
|
|
1281
|
+
if has_table:
|
|
1282
|
+
new_text = top + "\n" + body + "\n" + tables
|
|
1283
|
+
else:
|
|
1284
|
+
new_text = (body + "\n" if body else "") + managed
|
|
1285
|
+
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
1286
|
+
except Exception as e:
|
|
1287
|
+
errs.append("%s: %r" % (path, e))
|
|
1288
|
+
return ";".join(errs) or None
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def sync_cli_config_now(agent_id):
|
|
1292
|
+
"""运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
|
|
1293
|
+
绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
|
|
1294
|
+
try:
|
|
1295
|
+
from . import modelhub
|
|
1296
|
+
entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
|
|
1297
|
+
if not entry:
|
|
1298
|
+
return ""
|
|
1299
|
+
b = modelhub.bindings().get(agent_id) or {}
|
|
1300
|
+
note = _sync_agent_injection(entry, b)
|
|
1301
|
+
if note:
|
|
1302
|
+
return "已自动注入 %s 配置:%s" % (agent_id, note)
|
|
1303
|
+
return ""
|
|
1304
|
+
except Exception as e:
|
|
1305
|
+
return "自动注入失败: %r" % e
|
|
1306
|
+
|
|
1307
|
+
|
|
1151
1308
|
# 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
|
|
1152
1309
|
# 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
|
|
1153
1310
|
_AGENT_INJECTORS = {
|
|
1154
1311
|
"claude-code": (("anthropic",), _sync_claude_settings),
|
|
1155
1312
|
"opencode": (("anthropic", "openai"), _sync_opencode_settings),
|
|
1156
1313
|
"qwencode": (("openai",), _sync_qwen_settings),
|
|
1314
|
+
"kimi-code": (("openai", "anthropic"), _sync_kimi_settings),
|
|
1157
1315
|
}
|
|
1158
1316
|
|
|
1159
1317
|
# 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
|