codebee 0.1.18 → 0.1.19

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.
@@ -0,0 +1,303 @@
1
+ # -*- coding: utf-8 -*-
2
+ """每日垃圾清理:只清 CodeBee 自己产生的可再生数据,绝不碰用户成果。
3
+
4
+ 垃圾来源(实测 data/ 923MB 构成):
5
+ - runs/<id>/steps/*.log 运行过程日志,单文件可达 22MB(保留 run.json
6
+ 与 report.md,运行记录/结果页签不受影响)
7
+ - publish/shots/ 发布过程截图证据
8
+ - data 根 *.bak* 配置改写工具留下的备份残留
9
+ - pending/ 运行中指挥信箱的过期附件
10
+ - publish/profiles/ 浏览器 profile 迁移残留(新版已搬家目录,
11
+ 旧位置 688MB 实测;仅当家目录同款平台目录
12
+ 存在——即迁移已完成——才按废弃清理)
13
+ - exports/ imports/ 备份模块自己的历史产物
14
+ - data 根 *.log 服务日志按体量截尾,不是按天删
15
+
16
+ 调度挂在 automation._tick(fire_due 自节流:启用 + 当天没清过才真跑)。
17
+ 配置在 data/settings.json(cleanup_enabled / cleanup_retention_days),
18
+ 状态在 data/cleanup.json(last_run/last_freed/history 供设置页展示)。
19
+
20
+ 发布浏览器 profile(家目录 ~/.codebee/publish_profiles,约 1GB)含平台
21
+ 登录态,清了要重新扫码——绝不进每日自动清理,只留手动入口
22
+ (run_cleanup(include_profiles=True))。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from . import paths, settings as settings_mod
32
+
33
+ DEFAULT_RETENTION_DAYS = 14
34
+ LOG_MAX_BYTES = 5 * 1024 * 1024 # 服务日志超过这个体量才截
35
+ LOG_KEEP_BYTES = 1 * 1024 * 1024 # 截尾保留的末段
36
+ STATE_NAME = "cleanup.json"
37
+
38
+
39
+ # ---------------------------------------------------------------- 状态
40
+
41
+ def _state_path():
42
+ return Path(paths.DATA_DIR) / STATE_NAME
43
+
44
+
45
+ def _load_state():
46
+ try:
47
+ d = json.loads(_state_path().read_text(encoding="utf-8"))
48
+ return d if isinstance(d, dict) else {}
49
+ except Exception:
50
+ return {}
51
+
52
+
53
+ def _save_state(st):
54
+ p = _state_path()
55
+ p.parent.mkdir(parents=True, exist_ok=True)
56
+ tmp = p.with_suffix(".tmp")
57
+ tmp.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
58
+ os.replace(str(tmp), str(p))
59
+
60
+
61
+ def _profiles_base(home=None):
62
+ """家目录浏览器 profile 根(与 publish/manager 同款落点)。home 供测试注入。"""
63
+ if home:
64
+ return Path(home) / ".codebee" / "publish_profiles"
65
+ return Path.home() / ".codebee" / "publish_profiles"
66
+
67
+
68
+ def _parse_local_dt(s):
69
+ try:
70
+ return time.mktime(time.strptime(str(s)[:19], "%Y-%m-%d %H:%M:%S"))
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ # ---------------------------------------------------------------- 扫描
76
+
77
+ def _run_ended(run_dir, cutoff_ts):
78
+ """run 目录整体是否过期:看 run.json 的 ended_at;没有就退回目录 mtime。"""
79
+ rj = run_dir / "run.json"
80
+ if rj.is_file():
81
+ ts = None
82
+ try:
83
+ ts = _parse_local_dt(json.loads(rj.read_text(encoding="utf-8"))
84
+ .get("ended_at"))
85
+ except Exception:
86
+ ts = None
87
+ if ts:
88
+ return ts < cutoff_ts
89
+ try:
90
+ return run_dir.stat().st_mtime < cutoff_ts
91
+ except OSError:
92
+ return False
93
+
94
+
95
+ def _plan_items(retention_days=None, include_profiles=False, home=None, now=None):
96
+ """扫描各类垃圾,返回条目列表。每条:key/label/note/bytes/count/files,
97
+ files 元素为 {path, op},op ∈ delete | rmtree | truncate。"""
98
+ now = now or time.time()
99
+ try:
100
+ days = int(retention_days if retention_days is not None
101
+ else settings_mod.load().get("cleanup_retention_days"))
102
+ except (TypeError, ValueError):
103
+ days = DEFAULT_RETENTION_DAYS
104
+ days = max(1, min(365, days))
105
+ cutoff = now - days * 86400
106
+ data_dir = Path(paths.DATA_DIR)
107
+ items = []
108
+
109
+ def add(key, label, note, files):
110
+ size = 0
111
+ good = []
112
+ for f in files:
113
+ try:
114
+ p = Path(f["path"])
115
+ if f["op"] == "truncate":
116
+ sz = p.stat().st_size - LOG_KEEP_BYTES
117
+ elif p.is_dir():
118
+ sz = sum(x.stat().st_size for x in p.rglob("*") if x.is_file())
119
+ else:
120
+ sz = p.stat().st_size
121
+ except OSError:
122
+ continue
123
+ # truncate 只在有得砍时才留;delete/rmtree 即使 0 字节(空目录)也要清
124
+ if sz > 0 or f["op"] != "truncate":
125
+ good.append(f)
126
+ size += max(0, sz)
127
+ if good:
128
+ items.append({"key": key, "label": label, "note": note,
129
+ "bytes": size, "count": len(good), "files": good})
130
+
131
+ # 1) 过期运行的过程日志(保留 run.json / report.md / 封面)
132
+ runs_dir = data_dir / "runs"
133
+ files = []
134
+ if runs_dir.is_dir():
135
+ for rd in runs_dir.iterdir():
136
+ if not rd.is_dir() or not _run_ended(rd, cutoff):
137
+ continue
138
+ steps = rd / "steps"
139
+ if steps.is_dir():
140
+ files.append({"path": str(steps), "op": "rmtree"})
141
+ add("runs_old_logs", "运行过程日志",
142
+ "保留运行记录与结果报告,只删过程中的原始日志", files)
143
+
144
+ # 2) 发布截图
145
+ shots = data_dir / "publish" / "shots"
146
+ add("publish_shots", "发布截图", "发布过程留档截图,超期即清",
147
+ [{"path": str(p), "op": "delete"}
148
+ for p in (shots.iterdir() if shots.is_dir() else [])
149
+ if p.is_file() and p.stat().st_mtime < cutoff])
150
+
151
+ # 3) 配置备份残留 *.bak*
152
+ add("bak_files", "配置备份残留", "改配置时自动留的 .bak 副本,超期即清",
153
+ [{"path": str(p), "op": "delete"}
154
+ for p in data_dir.glob("*.bak*")
155
+ if p.is_file() and p.stat().st_mtime < cutoff])
156
+
157
+ # 4) 过期指挥信箱(运行早已结束,附件永远不会被消费)
158
+ pend = data_dir / "pending"
159
+ files = []
160
+ for p in (pend.iterdir() if pend.is_dir() else []):
161
+ try:
162
+ if p.stat().st_mtime >= cutoff:
163
+ continue
164
+ except OSError:
165
+ continue
166
+ files.append({"path": str(p), "op": "rmtree" if p.is_dir() else "delete"})
167
+ add("pending_stale", "过期指挥信箱", "已结束任务的运行中留言附件",
168
+ files)
169
+
170
+ # 5) 浏览器 profile 迁移残留:家目录同款平台目录存在(迁移已完成)才清
171
+ legacy = data_dir / "publish" / "profiles"
172
+ files = []
173
+ if legacy.is_dir():
174
+ for p in legacy.iterdir():
175
+ if not p.is_dir():
176
+ continue
177
+ try:
178
+ if (_profiles_base(home) / p.name).exists():
179
+ files.append({"path": str(p), "op": "rmtree"})
180
+ except OSError:
181
+ continue
182
+ add("legacy_profiles", "旧版浏览器缓存残留",
183
+ "发布浏览器 profile 已迁往用户主目录,旧位置属废弃残留", files)
184
+
185
+ # 6) 历史备份包(导出 zip / 导入前自动备份,超期即清)
186
+ files = []
187
+ for d in ("exports", "imports"):
188
+ base = data_dir / d
189
+ for p in (base.iterdir() if base.is_dir() else []):
190
+ try:
191
+ if p.stat().st_mtime < cutoff:
192
+ files.append({"path": str(p),
193
+ "op": "rmtree" if p.is_dir() else "delete"})
194
+ except OSError:
195
+ continue
196
+ add("backups_old", "历史备份包", "导出与导入前自动备份的旧 zip", files)
197
+
198
+ # 7) 服务日志截尾(不删文件,砍掉前段)
199
+ files = [{"path": str(p), "op": "truncate"}
200
+ for p in data_dir.glob("*.log")
201
+ if p.is_file() and p.stat().st_size > LOG_MAX_BYTES]
202
+ add("logs_truncate", "服务日志瘦身", "只保留每个日志的末尾 1MB", files)
203
+
204
+ # 8) 手动:发布浏览器缓存(含登录态,清了要重新扫码;绝不自动清)
205
+ if include_profiles:
206
+ base = _profiles_base(home)
207
+ files = [{"path": str(p), "op": "rmtree"}
208
+ for p in (base.iterdir() if base.is_dir() else []) if p.is_dir()]
209
+ add("publish_profiles", "发布浏览器缓存",
210
+ "含平台登录态,清理后发布时需重新扫码登录", files)
211
+ return {"retention_days": days, "items": items,
212
+ "total_bytes": sum(i["bytes"] for i in items)}
213
+
214
+
215
+ def plan(retention_days=None, include_profiles=False, home=None):
216
+ return _plan_items(retention_days=retention_days,
217
+ include_profiles=include_profiles, home=home)
218
+
219
+
220
+ def status():
221
+ """设置页一次拉全:配置 + 上次清理状态 + 当前可清理预估。"""
222
+ cfg = settings_mod.load()
223
+ return {"config": {"enabled": bool(cfg.get("cleanup_enabled")),
224
+ "retention_days": cfg.get("cleanup_retention_days")},
225
+ "state": _load_state(), "plan": plan()}
226
+
227
+
228
+ # ---------------------------------------------------------------- 执行
229
+
230
+ def _delete_path(p: Path, op):
231
+ if op == "rmtree":
232
+ import shutil
233
+ shutil.rmtree(str(p), ignore_errors=False)
234
+ elif op == "truncate":
235
+ with open(str(p), "rb") as f:
236
+ f.seek(-LOG_KEEP_BYTES, 2)
237
+ tail = f.read()
238
+ tmp = p.with_suffix(".tmp-cl")
239
+ tmp.write_bytes(tail)
240
+ os.replace(str(tmp), str(p))
241
+ else:
242
+ p.unlink()
243
+
244
+
245
+ def run_cleanup(retention_days=None, include_profiles=False):
246
+ """按当前扫描结果执行清理。返回汇总并落状态(供设置页展示)。"""
247
+ scan = _plan_items(retention_days=retention_days,
248
+ include_profiles=include_profiles)
249
+ freed = 0
250
+ done = []
251
+ errors = []
252
+ for item in scan["items"]:
253
+ got = 0
254
+ n = 0
255
+ for f in item["files"]:
256
+ try:
257
+ p = Path(f["path"])
258
+ if f["op"] == "truncate":
259
+ sz = p.stat().st_size - LOG_KEEP_BYTES
260
+ elif p.is_dir():
261
+ sz = sum(x.stat().st_size for x in p.rglob("*") if x.is_file())
262
+ else:
263
+ sz = p.stat().st_size
264
+ _delete_path(p, f["op"])
265
+ got += max(0, sz)
266
+ n += 1
267
+ except OSError as e:
268
+ errors.append("%s:%s" % (f["path"], e.strerror or e))
269
+ if n:
270
+ done.append({"key": item["key"], "label": item["label"],
271
+ "bytes": got, "count": n})
272
+ freed += got
273
+ result = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "freed": freed,
274
+ "items": done, "errors": errors[:10],
275
+ "include_profiles": bool(include_profiles)}
276
+ st = _load_state()
277
+ st["last_run"] = result["ts"]
278
+ st["last_freed"] = freed
279
+ st["last_result"] = result
280
+ hist = st.get("history") or []
281
+ hist.insert(0, {"ts": result["ts"], "freed": freed})
282
+ st["history"] = hist[:30]
283
+ _save_state(st)
284
+ try:
285
+ from . import store
286
+ store.bump_state()
287
+ except Exception:
288
+ pass
289
+ return result
290
+
291
+
292
+ def fire_due():
293
+ """automation._tick 每拍调用:启用且当天没清过才真跑,否则零开销返回。"""
294
+ try:
295
+ cfg = settings_mod.load()
296
+ except Exception:
297
+ return None
298
+ if not cfg.get("cleanup_enabled"):
299
+ return None
300
+ today = time.strftime("%Y-%m-%d")
301
+ if str(_load_state().get("last_run") or "").startswith(today):
302
+ return None
303
+ return run_cleanup()
package/app/core/flows.py CHANGED
@@ -76,7 +76,7 @@ BUILTIN_FLOWS = [
76
76
  "note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
77
77
  {"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
78
78
  "goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
79
- "note": "抓取七猫排行榜公开数据 → AI 提炼热门题材/人设/差异化切入点(快档直出报告)"},
79
+ "note": "抓取七猫+番茄排行榜公开数据 → AI 提炼跨平台热门题材/人设/差异化切入点(快档直出报告)"},
80
80
  {"id": "research", "name": "调研报告", "icon": "i-file-search", "engine": "review", "builtin": True,
81
81
  "manuscript": "report.md",
82
82
  "rubric": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
package/app/core/jobs.py CHANGED
@@ -156,6 +156,18 @@ def cancel(run_id):
156
156
  ev = CANCELS.get(run_id)
157
157
  if ev:
158
158
  ev.set()
159
+ # 强制终止语义:置位事件后立刻把 running 落成 cancelled 终态,
160
+ # UI 即刻翻牌,不等流水线走到下一个检查点(CLI 子进程树由取消
161
+ # 事件在 run_process 的 0.4s 轮询里秒杀;内置智能体生成中的 HTTP
162
+ # 由取消等待方放弃)。expected_status 守卫保证已正常结束的运行
163
+ # 不被误改;流水线随后照常收尾,终态幂等。
164
+ try:
165
+ from . import store
166
+ store.update_run(run_id, expected_status="running",
167
+ status="cancelled", ended_at=_now(),
168
+ error="用户主动取消")
169
+ except Exception:
170
+ pass
159
171
  return True
160
172
  # 事件不存在=任务还在队列里没被 worker 拿起:直接落终态(取消事件在
161
173
  # worker 起跑时才创建,排队任务点取消会在这里漏掉——起跑后再杀一遍)。
@@ -576,12 +588,13 @@ def _do_mgmt(job, ev):
576
588
  cost_usd=res.get("cost_usd", 0.0), tokens=res.get("tokens", 0))
577
589
  else:
578
590
  store.finish_step(run_id, step["n"], "failed", summary="未知操作 %s" % op)
579
- # 以步骤状态汇总 run 状态
591
+ # 以步骤状态汇总 run 状态;expected_status 守卫:用户已强制终止的运行
592
+ # 保持 cancelled,不被步骤汇总改写成 failed(被杀的步骤记的就是非 done)
580
593
  run = store.get_run(run_id)
581
594
  statuses = [s["status"] for s in (run.get("steps") if run else [])] or ["failed"]
582
595
  final = "done" if all(s == "done" for s in statuses) else "failed"
583
596
  suffix = "(AI 修复成功)" if ok and len((run.get("steps") if run else [])) > 1 else ""
584
- store.update_run(run_id, status=final, ended_at=_now(),
597
+ store.update_run(run_id, expected_status="running", status=final, ended_at=_now(),
585
598
  summary=("%s %s %s%s" % (entry.get("name"), op,
586
599
  "完成" if final == "done" else "失败", suffix)))
587
600
 
@@ -600,7 +613,9 @@ def _do_selfupgrade(job):
600
613
  summary="升级完成,点「重启」生效" if res["ok"]
601
614
  else ("升级失败: " + res["error"][:300]),
602
615
  exit_code=res.get("exit_code"))
603
- store.update_run(run_id, status="done" if res["ok"] else "failed", ended_at=_now(),
616
+ # expected_status 守卫:用户已强制终止的运行保持 cancelled,不被改写
617
+ store.update_run(run_id, expected_status="running",
618
+ status="done" if res["ok"] else "failed", ended_at=_now(),
604
619
  summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
605
620
 
606
621
 
@@ -619,7 +634,10 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
619
634
  if agent is None or agent.get("mode") != "real":
620
635
  return False # 无真实智能体可用,维持原失败
621
636
  try:
622
- log_tail = runner.tail_decoded(orig_log.read_bytes(), 2000) if orig_log.exists() else "(无输出)"
637
+ # 日志尾巴喂进 AI 修复提示词:先洗终端噪声,否则 ANSI 转义/乱码墙会被
638
+ # 模型当成"日志原文"照抄进修复推理里
639
+ log_tail = runner.clean_cli_text(
640
+ runner.tail_decoded(orig_log.read_bytes(), 2000)) if orig_log.exists() else "(无输出)"
623
641
  except Exception:
624
642
  log_tail = "(日志不可读)"
625
643
  import shutil
@@ -6,8 +6,10 @@
6
6
  - 知识(knowledge)记「已知是这样」——领域事实/平台规则/结论/方法论,
7
7
  来源=run 产出材料(调研报告/文档)。两者输入源不同,提炼互不双写。
8
8
 
9
- 质量闸门:自动提炼的条目一律落 draft(草稿态),人工「转正」后才参与注入——
10
- 垃圾知识进了提示词比没有知识更糟;手动新建即视为已确认(直接 approved)。
9
+ 质量闸门:条目默认直接转正(approved)参与注入——用户拍板:人工把关太重,
10
+ 提炼提示词里的「只保留有明确复用价值的」约束兜质量。status 字段保留 draft
11
+ 枚举向后兼容,手动新建同样直接 approved;migrate_drafts_approved() 在启动时
12
+ 把历史草稿一次性转正。
11
13
 
12
14
  注入纪律(与教训反着来):教训是全量小注(top-8),知识默认不注入——
13
15
  scope 命中且库里有 approved 条目才成块,按与任务目标的相关性取 top,
@@ -299,12 +301,15 @@ def learn_from_run(run_id):
299
301
  """运行结束后由编排者从产出材料提炼知识条目(草稿态)。返回写入条数。
300
302
 
301
303
  知识没有便宜的兜底路径:无编排者/无产出/非真实运行(mock)一律静默跳过,
302
- 宁缺毋滥——教训库的规则兜底搬到这里只会制造垃圾知识。
304
+ 宁缺毋滥——教训库的规则兜底搬到这里只会制造垃圾知识。只有正常跑完(done)
305
+ 的运行才提炼:失败/取消的运行产物是半成品,据此沉淀的知识会污染知识库。
303
306
  """
304
307
  from . import store
305
308
  run = store.get_run(run_id)
306
309
  if not run:
307
310
  return 0
311
+ if (run.get("status") or "") != "done":
312
+ return 0
308
313
  task = store.get_task(run.get("task_id")) if run.get("task_id") else None
309
314
  if not task:
310
315
  return 0
@@ -339,7 +344,7 @@ def learn_from_run(run_id):
339
344
  as_of = _now()[:10]
340
345
  if upsert_entry(task.get("type") or "*", x["title"], x["body"],
341
346
  tags=x.get("tags"), source=run_id,
342
- as_of=as_of, status="draft"):
347
+ as_of=as_of, status="approved"):
343
348
  n += 1
344
349
  except Exception:
345
350
  return n
@@ -356,6 +361,23 @@ def learn_async(run_id):
356
361
  threading.Thread(target=_run, name="kb-learn", daemon=True).start()
357
362
 
358
363
 
364
+ def migrate_drafts_approved():
365
+ """一次性迁移:草稿闸门退役(默认直接转正),把历史 draft 全部转正。
366
+
367
+ 幂等——没有 draft 时零写入。返回转正条数。"""
368
+ with _LOCK:
369
+ data = _load()
370
+ items = data.get("entries") or []
371
+ dirty = [x for x in items if (x.get("status") or "draft") != "approved"]
372
+ if not dirty:
373
+ return 0
374
+ for x in dirty:
375
+ x["status"] = "approved"
376
+ x["updated_at"] = _now()
377
+ _save(data)
378
+ return len(dirty)
379
+
380
+
359
381
  def view():
360
382
  """知识库总览(给 UI/API):条目 + 标签聚合 + 草稿数 + 账龄标记。"""
361
383
  entries = list_entries()
@@ -859,6 +859,21 @@ def _toml_top_set(text, key, value):
859
859
  return eol.join(lines).rstrip("\r\n") + eol, True
860
860
 
861
861
 
862
+ _TEST_HOST_SUFFIXES = (".test", ".example", ".invalid", ".localhost")
863
+
864
+
865
+ def _is_dead_endpoint(base):
866
+ """明显打不通的测试/保留地址(*.test、*.example、example.com 等)。
867
+ 这类端点落进 config.toml 后 codex 每次请求都撞死墙——2026-09-20
868
+ orch/p1.test 残留反复劫持全局配置的事故教训:宁可不写,不写必死端点。"""
869
+ host = (base or "").split("://", 1)[-1].split("/", 1)[0].split("@")[-1]
870
+ host = host.split(":")[0].lower().rstrip(".")
871
+ if not host:
872
+ return True
873
+ return (host in ("example.com", "example.org", "example.net", "localhost")
874
+ or host.endswith(_TEST_HOST_SUFFIXES))
875
+
876
+
862
877
  def _sync_codex_settings(entry, model, cp):
863
878
  """codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
864
879
  ([model_providers.orch] 段 + 顶层 model_provider/model)。
@@ -867,6 +882,8 @@ def _sync_codex_settings(entry, model, cp):
867
882
  没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
868
883
  不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
869
884
  _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
885
+ if _is_dead_endpoint(cp.get("base_url")):
886
+ return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
870
887
  path = _config_path(entry)
871
888
  if not path:
872
889
  return "codex 配置路径无效"
@@ -1459,6 +1476,12 @@ def sync_runtime_config(agent):
1459
1476
  break
1460
1477
  if entry is None or not (entry.get("config") or {}).get("path"):
1461
1478
  return
1479
+ if entry["id"] in ("codex-cli", "codex"):
1480
+ return # codex 绝不在运行防线上落盘(2026-09-20 orch 劫持事故):
1481
+ # 编排步骤走 runner 的 -c 一次性注入,不依赖 config.toml;
1482
+ # 在这里写盘会把全局 ~/.codex/config.toml 的 model_provider
1483
+ # 顶掉(CC Switch / 用户手动选的供应商被劫持)。交互 TUI
1484
+ # 场景由 launch() 自行同步,且端点防线在 _sync_codex_settings。
1462
1485
  from . import modelhub
1463
1486
  binding = modelhub.resolve_binding(entry["id"]) or {}
1464
1487
  model = (binding.get("model") or "").strip()
@@ -1631,7 +1654,8 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
1631
1654
  # 2026-09-18 dsh 案)。
1632
1655
  refresh_update_async(entry)
1633
1656
  return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
1634
- "error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
1657
+ "error": "" if res["ok"] else (runner.clean_cli_text(res["stderr"])[-800:]
1658
+ or "退出码 %s" % res["exit_code"])}
1635
1659
 
1636
1660
 
1637
1661
  def refresh_update_async(entry):
@@ -218,9 +218,19 @@ def install_files(pack_id, name, files, extra=None):
218
218
  """通用安装入口:任意 {安装相对路径: 文本内容} 写进用户包目录并记账。
219
219
  内置市场包(install)与外部目录插件(market_remote)共用这一条落地通道,
220
220
  避让 / 安装标记 / market.json 记账 / 幂等纪律完全一致;extra 追加进记账记录
221
- (如外部插件的来源与版本)。"""
221
+ (如外部插件的来源与版本)。
222
+
223
+ 装前静态扫描(借鉴 NVIDIA SkillSpector):安装内容扫危险模式,风险行写进
224
+ 返回值与记账——提示不拦阻(用户仍可装),但危险必须被看见。"""
222
225
  if not files or not any(str(v).strip() for v in files.values()):
223
226
  return None, "包内容为空: %s" % pack_id
227
+ # 装前扫描:全文件合并扫一遍(纯内存静态规则)
228
+ scan_note = ""
229
+ try:
230
+ from . import skill_scan
231
+ scan_note = skill_scan.scan_summary("\n".join(str(v) for v in files.values()))
232
+ except Exception:
233
+ scan_note = ""
224
234
  with _LOCK:
225
235
  udir = _user_pack_dir()
226
236
  reg = _load_registry()
@@ -249,12 +259,14 @@ def install_files(pack_id, name, files, extra=None):
249
259
  except OSError as e:
250
260
  return None, "写入用户技能库失败: %s" % e
251
261
  record = {"file": target, "files": written, "installed_at": _now()}
262
+ if scan_note:
263
+ record["scan"] = scan_note # 危险模式扫描结果随包记账
252
264
  if extra:
253
265
  record.update(extra)
254
266
  installed[pack_id] = record
255
267
  _save_registry(reg)
256
268
  return {"ok": True, "id": pack_id, "name": name, "file": target,
257
- "already": already}, None
269
+ "already": already, "scan": scan_note}, None
258
270
 
259
271
 
260
272
  def remove(pack_id):
@@ -835,6 +835,12 @@ def _is_codex_target(target):
835
835
  return (target or "").strip().lower() in ("codex-cli", "codex", "codex-code")
836
836
 
837
837
 
838
+ def _is_aider_target(target):
839
+ """aider 的绑定键与 orch.kind 同名(catalog id=aider);兜底前缀匹配防变体。"""
840
+ t = (target or "").strip().lower()
841
+ return t == "aider" or t.startswith("aider-")
842
+
843
+
838
844
  def note_codex_wire_dead(provider_id, minutes=30):
839
845
  """codex 撞上 wire 不兼容的供应商 → 供应商级冷却(自动绕开的记账位)。
840
846
 
@@ -2037,8 +2043,18 @@ def _chain_entry_env(prov, model, target="", endpoint=None, key="", key_id="",
2037
2043
  "ANTHROPIC_AUTH_TOKEN": use_key}
2038
2044
  if model:
2039
2045
  out["env"]["ANTHROPIC_MODEL"] = model
2046
+ if _is_aider_target(target):
2047
+ # aider(litellm)不认 AUTH_TOKEN,只认 ANTHROPIC_API_KEY——缺了它
2048
+ # 直接报 LLM Provider NOT provided(2026-09-20 连载评审 aider 全灭根因)。
2049
+ # claude 绝不注入:x-api-key 与 Bearer 两种鉴权头网关挑食,不能混。
2050
+ out["env"]["ANTHROPIC_API_KEY"] = use_key
2040
2051
  else:
2041
2052
  out["env"] = {"ORCH_API_KEY": use_key}
2053
+ if _is_aider_target(target):
2054
+ # litellm 的 openai 通道读这对 env;base 语义与 codex chat wire 一致
2055
+ # (调用方拼 /chat/completions)
2056
+ out["env"]["OPENAI_API_KEY"] = use_key
2057
+ out["env"]["OPENAI_API_BASE"] = base
2042
2058
  wire_api = endpoint[2] if endpoint else prov.get("wire_api", "responses")
2043
2059
  out["codex_provider"] = {
2044
2060
  "name": "orch", "base_url": base,