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.
@@ -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 != "(游离 HEAD)" and back != branch) else base
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
- co = _git(wd, "checkout", "--quiet", target, timeout=60)
550
- if not co["ok"]:
551
- return refuse("切回 %s 失败:%s" % (target, (co["stderr"] or "")[-160:]))
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:])
@@ -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("status") in ("failing", "down")
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)
@@ -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" or run.get("status") != "done":
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 = bool(verdict.get("pass") or verdict.get("publishable"))
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 = 2 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
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
- _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
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", readonly=True,
324
- timeout=180, cancel_event=ev, log_path=str(log_abs))
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",
@@ -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": prov.get("base_url") or "",
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 注入大概率无效,打开时明确告知而非静默废