codebee 0.1.5 → 0.1.7
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 +16 -0
- package/README.md +431 -422
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +23 -0
- package/app/core/health.py +12 -1
- package/app/core/jobs.py +10 -2
- package/app/core/manager.py +87 -10
- package/app/core/modelhub.py +2883 -2845
- package/app/core/pipeline.py +120 -60
- package/app/core/registry.py +4 -0
- package/app/core/remote.py +310 -303
- package/app/core/router.py +5 -4
- package/app/core/runner.py +38 -6
- package/app/core/selfupdate.py +53 -16
- package/app/core/store.py +138 -47
- package/app/core/usage.py +51 -1
- package/app/main.py +219 -23
- package/app/ui/app.js +687 -222
- package/app/ui/i18n.js +43 -6
- package/app/ui/icons/bee.svg +79 -0
- package/app/ui/index.html +59 -46
- package/app/ui/style.css +1229 -328
- package/package.json +1 -1
package/app/core/automation.py
CHANGED
|
@@ -299,11 +299,36 @@ def _launch_run(t):
|
|
|
299
299
|
time.strftime("%m-%d %H:%M"))).strip()[:40],
|
|
300
300
|
"goal": t.get("prompt") or "",
|
|
301
301
|
"workdir": (t.get("workdir") or "").strip()}
|
|
302
|
-
task =
|
|
303
|
-
run =
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
task = None
|
|
303
|
+
run = None
|
|
304
|
+
try:
|
|
305
|
+
task = store.create_task(payload)
|
|
306
|
+
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
307
|
+
store.update_task_status(task["id"], "queued")
|
|
308
|
+
jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
|
|
309
|
+
"task_id": task["id"]})
|
|
310
|
+
return run["id"]
|
|
311
|
+
except Exception:
|
|
312
|
+
# A scheduler failure must not leave a task/run that looks queued forever.
|
|
313
|
+
# Keep the public error generic; the detailed traceback stays in the
|
|
314
|
+
# service log and the run record remains useful for diagnostics.
|
|
315
|
+
log.exception("automation: 运行入队失败 task=%s run=%s",
|
|
316
|
+
(task or {}).get("id"), (run or {}).get("id"))
|
|
317
|
+
if run:
|
|
318
|
+
try:
|
|
319
|
+
store.update_run(run["id"], status="failed",
|
|
320
|
+
error="任务入队失败,请稍后重试",
|
|
321
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
322
|
+
except Exception:
|
|
323
|
+
log.exception("automation: 运行失败收口失败 run=%s", run.get("id"))
|
|
324
|
+
elif task:
|
|
325
|
+
# create_run may fail before a run is available. The task was
|
|
326
|
+
# already persisted by create_task, so mark it failed as well.
|
|
327
|
+
try:
|
|
328
|
+
store.update_task_status(task["id"], "failed")
|
|
329
|
+
except Exception:
|
|
330
|
+
log.exception("automation: 任务失败收口失败 task=%s", task.get("id"))
|
|
331
|
+
raise
|
|
307
332
|
|
|
308
333
|
|
|
309
334
|
def _fire(snapshot, now):
|
package/app/core/catalog.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import copy
|
|
11
11
|
import json
|
|
12
12
|
import re
|
|
13
|
+
import sys
|
|
13
14
|
import threading
|
|
14
15
|
|
|
15
16
|
from . import paths
|
|
@@ -216,6 +217,18 @@ CONFIG_PATCH = {
|
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
|
|
220
|
+
# 安装命令平台修正({id: (install, upgrade)}):仅非 Windows 套用。winget 是
|
|
221
|
+
# Windows 包管理器、py 启动器 Windows 独有,这两类命令在 macOS/Linux 上必失败
|
|
222
|
+
# ——load() 幂等改写为等价的 npm/pip 渠道(与 CONFIG_PATCH 同策略:覆盖无风险,
|
|
223
|
+
# 旧数据不修正的话每次点安装/升级都注定失败)。
|
|
224
|
+
INSTALL_PATCH_NONWIN = {
|
|
225
|
+
"claude-code": ("npm install -g @anthropic-ai/claude-code",
|
|
226
|
+
"npm install -g @anthropic-ai/claude-code@latest"),
|
|
227
|
+
"aider": ("python3 -m pip install -U aider-chat",
|
|
228
|
+
"python3 -m pip install -U aider-chat"),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
219
232
|
def npm_pkg_name(cmd):
|
|
220
233
|
"""从 npm 安装命令里取包名(支持 @scope/name@latest)。
|
|
221
234
|
|
|
@@ -289,6 +302,15 @@ def _apply_config_patch(entries):
|
|
|
289
302
|
e["config"] = dict(patch)
|
|
290
303
|
|
|
291
304
|
|
|
305
|
+
def _apply_install_patch(entries):
|
|
306
|
+
if sys.platform == "win32":
|
|
307
|
+
return
|
|
308
|
+
for e in entries:
|
|
309
|
+
patch = INSTALL_PATCH_NONWIN.get(e.get("id"))
|
|
310
|
+
if patch:
|
|
311
|
+
e["install"], e["upgrade"] = patch
|
|
312
|
+
|
|
313
|
+
|
|
292
314
|
def _merge_new_defaults(entries):
|
|
293
315
|
"""把内置默认里「新增的」条目补进已加载清单(同 id 已存在则原样保留)。
|
|
294
316
|
|
|
@@ -321,6 +343,7 @@ def load(force=False):
|
|
|
321
343
|
_apply_resume_patch(entries)
|
|
322
344
|
_apply_launch_patch(entries)
|
|
323
345
|
_apply_config_patch(entries)
|
|
346
|
+
_apply_install_patch(entries)
|
|
324
347
|
_CACHE["entries"] = entries
|
|
325
348
|
return entries
|
|
326
349
|
|
package/app/core/health.py
CHANGED
|
@@ -59,6 +59,13 @@ def init(data_dir=None):
|
|
|
59
59
|
_PROVIDERS[name] = st
|
|
60
60
|
except (OSError, json.JSONDecodeError):
|
|
61
61
|
pass
|
|
62
|
+
# 「绑定链·」静态告警胶囊已从产品移除(2026-09-18:普通用户误伤,
|
|
63
|
+
# 用户拍板)——老版本持久化的条目启动即清,别让 0.1.6 的状态阴魂不散。
|
|
64
|
+
stale = [k for k in _PROVIDERS if k.startswith("绑定链·")]
|
|
65
|
+
if stale:
|
|
66
|
+
for k in stale:
|
|
67
|
+
_PROVIDERS.pop(k, None)
|
|
68
|
+
_persist()
|
|
62
69
|
if not _STARTED:
|
|
63
70
|
_STARTED = True
|
|
64
71
|
t = threading.Thread(target=_probe_loop, name="health-probe", daemon=True)
|
|
@@ -157,6 +164,8 @@ def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
|
157
164
|
_persist()
|
|
158
165
|
|
|
159
166
|
|
|
167
|
+
# ------------------------------------------------- 静态死链告警(绑定层)
|
|
168
|
+
|
|
160
169
|
# ---------------------------------------------------------------- 手动操作
|
|
161
170
|
|
|
162
171
|
def silence(provider: str, minutes: int = 0):
|
|
@@ -215,6 +224,7 @@ def snapshot():
|
|
|
215
224
|
"last_error": st.get("last_error") or "",
|
|
216
225
|
"alerting": alerting,
|
|
217
226
|
"silenced": silenced,
|
|
227
|
+
"static": bool(st.get("static")),
|
|
218
228
|
})
|
|
219
229
|
# 稳定排序:告警中 > 故障中 > 恢复 > 正常
|
|
220
230
|
rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
|
|
@@ -271,7 +281,8 @@ def _probe_loop():
|
|
|
271
281
|
try:
|
|
272
282
|
with _LOCK:
|
|
273
283
|
targets = [(name, st) for name, st in _PROVIDERS.items()
|
|
274
|
-
if st.get("
|
|
284
|
+
if not st.get("static") # 静态死链无端点可探,绑定恢复时自动解除
|
|
285
|
+
and st.get("status") in ("failing", "down")
|
|
275
286
|
and (st.get("probe_next_at") or 0) <= _now()]
|
|
276
287
|
for name, st in targets:
|
|
277
288
|
idx = int(st.get("probe_backoff_idx") or 0)
|
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
|
package/app/core/manager.py
CHANGED
|
@@ -9,7 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
+
import shlex
|
|
12
13
|
import shutil
|
|
14
|
+
import signal
|
|
13
15
|
import socket
|
|
14
16
|
import subprocess
|
|
15
17
|
import sys
|
|
@@ -21,7 +23,8 @@ from pathlib import Path
|
|
|
21
23
|
|
|
22
24
|
from . import catalog, paths, runner
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
# 非 Windows 置 0:POSIX 的 Popen 对非零 creationflags 抛 ValueError(runner 同款守卫)
|
|
27
|
+
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
25
28
|
VERSION_TTL = 300 # 版本缓存 5 分钟
|
|
26
29
|
|
|
27
30
|
_LOCK = threading.RLock()
|
|
@@ -69,6 +72,43 @@ def detect_entry(entry):
|
|
|
69
72
|
return {"installed": False, "detail": ""}
|
|
70
73
|
|
|
71
74
|
|
|
75
|
+
def _orphan_signature(cl):
|
|
76
|
+
"""「Tutti 专属调用签名」判定(两个平台的清扫共用)。"""
|
|
77
|
+
return (("opencode" in cl and "--model" in cl)
|
|
78
|
+
or ("codex" in cl and "--skip-git-repo-check" in cl)
|
|
79
|
+
or ("kimi-code" in cl and "main.mjs" in cl))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sweep_orphans_posix():
|
|
83
|
+
"""POSIX 版清扫:ps 一次拉全量,签名同 Windows。孤儿判据不同——POSIX 的
|
|
84
|
+
孤儿进程会被内核过继给 1 号进程(launchd/init),Windows 则保留死掉的
|
|
85
|
+
ppid,所以这里认 ppid==1;用户手动在终端里跑的同名 CLI 父进程是活着的
|
|
86
|
+
shell(ppid!=1),不会被误杀。"""
|
|
87
|
+
try:
|
|
88
|
+
r = subprocess.run(["ps", "-eo", "pid=,ppid=,command="],
|
|
89
|
+
capture_output=True, timeout=60)
|
|
90
|
+
killed = 0
|
|
91
|
+
for ln in r.stdout.decode("utf-8", "replace").splitlines():
|
|
92
|
+
parts = ln.strip().split(None, 2)
|
|
93
|
+
if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit():
|
|
94
|
+
continue
|
|
95
|
+
pid, ppid, cmd = int(parts[0]), int(parts[1]), parts[2]
|
|
96
|
+
if pid == 1 or ppid != 1 or not _orphan_signature(cmd.lower()):
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
os.killpg(pid, signal.SIGKILL) # 服务 spawn 用了 start_new_session,pgid==pid
|
|
100
|
+
killed += 1
|
|
101
|
+
except Exception:
|
|
102
|
+
try:
|
|
103
|
+
os.kill(pid, signal.SIGKILL)
|
|
104
|
+
killed += 1
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return killed
|
|
108
|
+
except Exception:
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
72
112
|
def sweep_orphan_cli_processes():
|
|
73
113
|
"""启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
|
|
74
114
|
/T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
|
|
@@ -78,7 +118,11 @@ def sweep_orphan_cli_processes():
|
|
|
78
118
|
按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
|
|
79
119
|
opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
|
|
80
120
|
codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
|
|
121
|
+
kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
|
|
122
|
+
僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
|
|
81
123
|
claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
|
|
124
|
+
if os.name != "nt":
|
|
125
|
+
return _sweep_orphans_posix()
|
|
82
126
|
ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
|
|
83
127
|
"System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
84
128
|
if not os.path.isfile(ps_exe):
|
|
@@ -99,9 +143,7 @@ def sweep_orphan_cli_processes():
|
|
|
99
143
|
for i in items:
|
|
100
144
|
cl = str(i.get("CommandLine") or "").lower()
|
|
101
145
|
ppid = i.get("ParentProcessId")
|
|
102
|
-
|
|
103
|
-
or ("codex" in cl and "--skip-git-repo-check" in cl))
|
|
104
|
-
if not is_ours or ppid in live or not i.get("ProcessId"):
|
|
146
|
+
if not _orphan_signature(cl) or ppid in live or not i.get("ProcessId"):
|
|
105
147
|
continue
|
|
106
148
|
try:
|
|
107
149
|
subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
|
|
@@ -165,6 +207,8 @@ def _uwp_version(package_dir):
|
|
|
165
207
|
|
|
166
208
|
|
|
167
209
|
def _exe_version(path):
|
|
210
|
+
if os.name != "nt":
|
|
211
|
+
return None # exe 版本探测是 Windows 专属功能(PowerShell 读 PE 资源)
|
|
168
212
|
try:
|
|
169
213
|
r = subprocess.run(
|
|
170
214
|
["powershell", "-NoProfile", "-Command",
|
|
@@ -188,7 +232,11 @@ def version_of(entry):
|
|
|
188
232
|
cli = (entry.get("detect") or {}).get("cli")
|
|
189
233
|
if cli and det.get("installed"):
|
|
190
234
|
try:
|
|
191
|
-
|
|
235
|
+
# Windows 下 CLI 可能是 npm .cmd 垫片,须经 cmd /c 才能直接点名跑;
|
|
236
|
+
# POSIX 没有垫片,符号链接直接跑即可
|
|
237
|
+
probe = ["cmd", "/c", cli, "--version"] if os.name == "nt" \
|
|
238
|
+
else [cli, "--version"]
|
|
239
|
+
r = subprocess.run(probe, capture_output=True,
|
|
192
240
|
creationflags=CREATE_NO_WINDOW, timeout=20)
|
|
193
241
|
out = (r.stdout or b"").decode("utf-8", "replace").strip()
|
|
194
242
|
if not out:
|
|
@@ -832,6 +880,11 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
832
880
|
name = cp.get("name", "orch")
|
|
833
881
|
def q(v):
|
|
834
882
|
return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
|
|
883
|
+
if (cp.get("wire_api") or "responses") == "chat":
|
|
884
|
+
# codex 0.154+ 起 chat wire 被官方移除,写进 config.toml 会让 CLI 连配置
|
|
885
|
+
# 都载入不了(Error loading config.toml)——宁可明确拒绝也不落坏配置。
|
|
886
|
+
return ("供应商只有 chat completions wire,codex 0.154+ 已移除支持,未写入"
|
|
887
|
+
" config.toml——请为 codex 绑定 responses 兼容的供应商")
|
|
835
888
|
pairs = [("name", q(cp.get("name", name))),
|
|
836
889
|
("base_url", q(cp.get("base_url", ""))),
|
|
837
890
|
("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
|
|
@@ -1432,9 +1485,13 @@ def launch(entry, open_browser=True):
|
|
|
1432
1485
|
spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
|
|
1433
1486
|
try:
|
|
1434
1487
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1435
|
-
|
|
1488
|
+
# 重定向语法 sh 与 cmd 通用,只换解释器;start_new_session 让子服务
|
|
1489
|
+
# 脱离本服务进程组(杀树/退出互不牵连)
|
|
1490
|
+
shell = ["cmd", "/c"] if os.name == "nt" else ["/bin/sh", "-c"]
|
|
1491
|
+
subprocess.Popen(shell + [spawn_cmd], cwd=str(paths.ROOT), env=env,
|
|
1436
1492
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
1437
|
-
stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
|
1493
|
+
stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW,
|
|
1494
|
+
start_new_session=(os.name != "nt"))
|
|
1438
1495
|
except Exception as e:
|
|
1439
1496
|
return {"ok": False, "error": "无法启动服务: %r" % e}
|
|
1440
1497
|
if open_browser:
|
|
@@ -1444,8 +1501,25 @@ def launch(entry, open_browser=True):
|
|
|
1444
1501
|
"message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
|
|
1445
1502
|
% (name, bare, (";" + extra) if extra else "")}
|
|
1446
1503
|
|
|
1504
|
+
if sys.platform == "darwin":
|
|
1505
|
+
# Terminal.app 新开窗口跑交互 TUI;do script 的命令串常驻窗口,等价
|
|
1506
|
+
# Windows 的 cmd /k(CLI 退出后窗口保留,报错不至于一闪而过)。
|
|
1507
|
+
# 两层转义各管各的:shlex.quote 管 shell 层(cd 路径的引号),
|
|
1508
|
+
# 反斜杠/双引号替换管 AppleScript 字符串层。
|
|
1509
|
+
shell_cmd = "cd %s && %s" % (shlex.quote(str(paths.ROOT)), cmd)
|
|
1510
|
+
asc = 'tell application "Terminal" to do script ' + \
|
|
1511
|
+
'"' + shell_cmd.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
1512
|
+
try:
|
|
1513
|
+
subprocess.Popen(["osascript", "-e", asc], cwd=str(paths.ROOT), env=env,
|
|
1514
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
1515
|
+
stderr=subprocess.DEVNULL)
|
|
1516
|
+
except Exception as e:
|
|
1517
|
+
return {"ok": False, "error": "无法打开终端窗口: %r" % e}
|
|
1518
|
+
return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
|
|
1519
|
+
% (name, ("(" + extra + ")") if extra else "")}
|
|
1520
|
+
|
|
1447
1521
|
if sys.platform != "win32":
|
|
1448
|
-
return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
|
|
1522
|
+
return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows/macOS"}
|
|
1449
1523
|
# start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
|
|
1450
1524
|
# 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
|
|
1451
1525
|
argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
|
|
@@ -1536,7 +1610,10 @@ def check_update(entry, force=False):
|
|
|
1536
1610
|
cmd = entry.get("install") or entry.get("upgrade") or ""
|
|
1537
1611
|
pkg = _npm_pkg_name(cmd)
|
|
1538
1612
|
if pkg:
|
|
1539
|
-
|
|
1613
|
+
# Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑
|
|
1614
|
+
npm_view = ["cmd", "/c", "npm", "view", pkg, "version"] if os.name == "nt" \
|
|
1615
|
+
else ["npm", "view", pkg, "version"]
|
|
1616
|
+
r = runner.run_process(argv=npm_view, timeout=90)
|
|
1540
1617
|
latest = ""
|
|
1541
1618
|
if r["ok"]:
|
|
1542
1619
|
for line in (r["stdout"] or "").splitlines():
|
|
@@ -1550,7 +1627,7 @@ def check_update(entry, force=False):
|
|
|
1550
1627
|
result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
|
|
1551
1628
|
if not result["updatable"]:
|
|
1552
1629
|
result["note"] = "已是最新版本"
|
|
1553
|
-
elif "winget" in cmd:
|
|
1630
|
+
elif os.name == "nt" and "winget" in cmd:
|
|
1554
1631
|
m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
|
|
1555
1632
|
wid = m.group(1) if m else None
|
|
1556
1633
|
if not wid:
|