codebee 0.1.6 → 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 +8 -0
- package/README.md +431 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +23 -0
- package/app/core/health.py +7 -50
- package/app/core/manager.py +80 -11
- package/app/core/modelhub.py +2883 -2896
- package/app/core/pipeline.py +9 -15
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +26 -3
- package/app/core/selfupdate.py +47 -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 +519 -156
- package/app/ui/i18n.js +30 -1
- package/app/ui/index.html +57 -45
- package/app/ui/style.css +1056 -2
- 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)
|
|
@@ -159,56 +166,6 @@ def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
|
159
166
|
|
|
160
167
|
# ------------------------------------------------- 静态死链告警(绑定层)
|
|
161
168
|
|
|
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
|
-
|
|
212
169
|
# ---------------------------------------------------------------- 手动操作
|
|
213
170
|
|
|
214
171
|
def silence(provider: str, minutes: int = 0):
|
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 是客户端-服务端架构,新
|
|
@@ -81,6 +121,8 @@ def sweep_orphan_cli_processes():
|
|
|
81
121
|
kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
|
|
82
122
|
僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
|
|
83
123
|
claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
|
|
124
|
+
if os.name != "nt":
|
|
125
|
+
return _sweep_orphans_posix()
|
|
84
126
|
ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
|
|
85
127
|
"System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
86
128
|
if not os.path.isfile(ps_exe):
|
|
@@ -101,10 +143,7 @@ def sweep_orphan_cli_processes():
|
|
|
101
143
|
for i in items:
|
|
102
144
|
cl = str(i.get("CommandLine") or "").lower()
|
|
103
145
|
ppid = i.get("ParentProcessId")
|
|
104
|
-
|
|
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"):
|
|
146
|
+
if not _orphan_signature(cl) or ppid in live or not i.get("ProcessId"):
|
|
108
147
|
continue
|
|
109
148
|
try:
|
|
110
149
|
subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
|
|
@@ -168,6 +207,8 @@ def _uwp_version(package_dir):
|
|
|
168
207
|
|
|
169
208
|
|
|
170
209
|
def _exe_version(path):
|
|
210
|
+
if os.name != "nt":
|
|
211
|
+
return None # exe 版本探测是 Windows 专属功能(PowerShell 读 PE 资源)
|
|
171
212
|
try:
|
|
172
213
|
r = subprocess.run(
|
|
173
214
|
["powershell", "-NoProfile", "-Command",
|
|
@@ -191,7 +232,11 @@ def version_of(entry):
|
|
|
191
232
|
cli = (entry.get("detect") or {}).get("cli")
|
|
192
233
|
if cli and det.get("installed"):
|
|
193
234
|
try:
|
|
194
|
-
|
|
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,
|
|
195
240
|
creationflags=CREATE_NO_WINDOW, timeout=20)
|
|
196
241
|
out = (r.stdout or b"").decode("utf-8", "replace").strip()
|
|
197
242
|
if not out:
|
|
@@ -1440,9 +1485,13 @@ def launch(entry, open_browser=True):
|
|
|
1440
1485
|
spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
|
|
1441
1486
|
try:
|
|
1442
1487
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1443
|
-
|
|
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,
|
|
1444
1492
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
1445
|
-
stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW
|
|
1493
|
+
stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW,
|
|
1494
|
+
start_new_session=(os.name != "nt"))
|
|
1446
1495
|
except Exception as e:
|
|
1447
1496
|
return {"ok": False, "error": "无法启动服务: %r" % e}
|
|
1448
1497
|
if open_browser:
|
|
@@ -1452,8 +1501,25 @@ def launch(entry, open_browser=True):
|
|
|
1452
1501
|
"message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
|
|
1453
1502
|
% (name, bare, (";" + extra) if extra else "")}
|
|
1454
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
|
+
|
|
1455
1521
|
if sys.platform != "win32":
|
|
1456
|
-
return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
|
|
1522
|
+
return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows/macOS"}
|
|
1457
1523
|
# start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
|
|
1458
1524
|
# 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
|
|
1459
1525
|
argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
|
|
@@ -1544,7 +1610,10 @@ def check_update(entry, force=False):
|
|
|
1544
1610
|
cmd = entry.get("install") or entry.get("upgrade") or ""
|
|
1545
1611
|
pkg = _npm_pkg_name(cmd)
|
|
1546
1612
|
if pkg:
|
|
1547
|
-
|
|
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)
|
|
1548
1617
|
latest = ""
|
|
1549
1618
|
if r["ok"]:
|
|
1550
1619
|
for line in (r["stdout"] or "").splitlines():
|
|
@@ -1558,7 +1627,7 @@ def check_update(entry, force=False):
|
|
|
1558
1627
|
result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
|
|
1559
1628
|
if not result["updatable"]:
|
|
1560
1629
|
result["note"] = "已是最新版本"
|
|
1561
|
-
elif "winget" in cmd:
|
|
1630
|
+
elif os.name == "nt" and "winget" in cmd:
|
|
1562
1631
|
m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
|
|
1563
1632
|
wid = m.group(1) if m else None
|
|
1564
1633
|
if not wid:
|