codebee 0.1.6 → 0.1.8
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 +18 -0
- package/README.md +415 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +45 -3
- package/app/core/errorlog.py +179 -0
- package/app/core/flows.py +6 -2
- package/app/core/gitmod.py +45 -1
- package/app/core/health.py +55 -61
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +101 -11
- package/app/core/modelhub.py +2952 -2896
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +425 -99
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +148 -13
- package/app/core/selfupdate.py +47 -16
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +160 -28
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/core/usage.py +51 -1
- package/app/main.py +379 -37
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1006 -266
- package/app/ui/i18n.js +107 -7
- package/app/ui/index.html +148 -55
- package/app/ui/style.css +1816 -2
- package/package.json +1 -1
package/app/core/jobs.py
CHANGED
|
@@ -24,8 +24,8 @@ _seq = 0
|
|
|
24
24
|
AI_REPAIR_PROMPT = """你是环境工程师。在 Windows 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
|
|
25
25
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
26
26
|
{"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
|
|
27
|
-
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是
|
|
28
|
-
|
|
27
|
+
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一。
|
|
28
|
+
给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
|
|
29
29
|
|
|
30
30
|
## 失败的命令
|
|
31
31
|
__CMD__
|
|
@@ -36,8 +36,10 @@ __LOG__
|
|
|
36
36
|
## 本机环境
|
|
37
37
|
__ENV__"""
|
|
38
38
|
|
|
39
|
-
# AI
|
|
40
|
-
|
|
39
|
+
# AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
|
|
40
|
+
# 两处永远不会漂移)
|
|
41
|
+
AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install",
|
|
42
|
+
"uv tool install")
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def _repair_command_allowed(cmd):
|
|
@@ -139,6 +141,20 @@ AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠
|
|
|
139
141
|
# 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
|
|
140
142
|
|
|
141
143
|
|
|
144
|
+
def _task_active_run(task_id, exclude_run_id=None):
|
|
145
|
+
"""该任务当前 queued/running 的运行(同任务单飞守卫用);无则 None。"""
|
|
146
|
+
try:
|
|
147
|
+
from . import store
|
|
148
|
+
for r in store.task_runs(task_id):
|
|
149
|
+
if r.get("id") == exclude_run_id:
|
|
150
|
+
continue
|
|
151
|
+
if r.get("status") in ("queued", "running"):
|
|
152
|
+
return r
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
142
158
|
def _maybe_auto_resume(run_id):
|
|
143
159
|
"""连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
|
|
144
160
|
|
|
@@ -157,11 +173,19 @@ def _maybe_auto_resume(run_id):
|
|
|
157
173
|
return False # 用户主动取消的运行绝不自动续跑
|
|
158
174
|
if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
|
|
159
175
|
return False
|
|
176
|
+
if _task_active_run(task["id"], exclude_run_id=run_id):
|
|
177
|
+
return False # 同任务已有运行排队/在跑:再排副本只会与之撞车
|
|
160
178
|
ok, err, new_run = store.retry_task(task["id"])
|
|
161
179
|
if not ok or not new_run:
|
|
162
180
|
return False
|
|
181
|
+
# 退避窗口要让用户看得见:把「预定入队时刻」写到 run 上,前端据此显示
|
|
182
|
+
# 「将在 HH:MM 自动续跑」而不是笼统的排队中(run 在建好到入队之间会
|
|
183
|
+
# 以 queued 状态干等 AUTO_RESUME_DELAY_S 秒)。
|
|
184
|
+
import time as _t
|
|
185
|
+
resume_at = _t.strftime("%Y-%m-%d %H:%M:%S",
|
|
186
|
+
_t.localtime(_t.time() + AUTO_RESUME_DELAY_S))
|
|
163
187
|
store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
|
|
164
|
-
auto_resumed_from=run_id)
|
|
188
|
+
auto_resumed_from=run_id, resume_enqueue_at=resume_at)
|
|
165
189
|
|
|
166
190
|
def _enqueue():
|
|
167
191
|
_QUEUE.put({"kind": "orchestration",
|
|
@@ -238,6 +262,54 @@ def resume_interrupted(limit=3):
|
|
|
238
262
|
return n
|
|
239
263
|
|
|
240
264
|
|
|
265
|
+
def _yield_duplicate(run_id):
|
|
266
|
+
"""同任务单飞(worker 出队时把关):系统续跑副本出队时若同任务已有
|
|
267
|
+
运行排队/在跑,取消自己让位——恢复副本与原轮并行跑只会双烧评审。
|
|
268
|
+
用户轮不在此拦(retry_task 建轮时已拒运行中任务)。返回 True 表示
|
|
269
|
+
本运行已落 cancelled,不要执行。"""
|
|
270
|
+
try:
|
|
271
|
+
from . import store
|
|
272
|
+
r0 = store.get_run(run_id)
|
|
273
|
+
if not r0 or not r0.get("auto_resumed_from"):
|
|
274
|
+
return False
|
|
275
|
+
other = _task_active_run(r0.get("task_id"), exclude_run_id=run_id)
|
|
276
|
+
if not other or other.get("kind") != "orchestration":
|
|
277
|
+
return False
|
|
278
|
+
store.update_run(run_id, status="cancelled", ended_at=_now(),
|
|
279
|
+
error="同任务已有运行在跑(%s),续跑副本自动让位" % other.get("id"))
|
|
280
|
+
return True
|
|
281
|
+
except Exception:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def requeue_pending(limit=10):
|
|
286
|
+
"""启动补队:队列在内存里,进程一死排队项就没人管了(2026-09-18
|
|
287
|
+
七猫 r-162724 排队僵尸案:续跑副本 created 后服务重启,Timer 随进程
|
|
288
|
+
蒸发,运行永远停在「排队中」)。重启后把近 24h 的遗留 queued 编排运行
|
|
289
|
+
重新入队;同任务已有在跑/排队的不重复补。返回补队条数。"""
|
|
290
|
+
try:
|
|
291
|
+
from . import store
|
|
292
|
+
except Exception:
|
|
293
|
+
return 0
|
|
294
|
+
n = 0
|
|
295
|
+
try:
|
|
296
|
+
for run in store.list_runs(200):
|
|
297
|
+
if n >= limit:
|
|
298
|
+
break
|
|
299
|
+
if run.get("status") != "queued" or run.get("kind") != "orchestration":
|
|
300
|
+
continue
|
|
301
|
+
if not run.get("task_id") or not _recent(run):
|
|
302
|
+
continue
|
|
303
|
+
if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
|
|
304
|
+
continue # 同任务已有更活跃的运行,别再排一份
|
|
305
|
+
_QUEUE.put({"kind": "orchestration", "run_id": run["id"],
|
|
306
|
+
"task_id": run["task_id"]})
|
|
307
|
+
n += 1
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
return n
|
|
311
|
+
|
|
312
|
+
|
|
241
313
|
def _worker():
|
|
242
314
|
global _alive
|
|
243
315
|
with _pool_lock:
|
|
@@ -262,6 +334,8 @@ def _worker():
|
|
|
262
334
|
r0 = store.get_run(run_id)
|
|
263
335
|
if r0 and r0.get("status") == "cancelled":
|
|
264
336
|
continue # task_done 由 finally 统一收口,不能在此重复
|
|
337
|
+
if job.get("kind") == "orchestration" and _yield_duplicate(run_id):
|
|
338
|
+
continue # 同任务单飞:续跑副本让位(已落 cancelled)
|
|
265
339
|
if job.get("kind") == "orchestration":
|
|
266
340
|
from . import pipeline
|
|
267
341
|
pipeline.execute_run(run_id)
|
|
@@ -314,12 +388,21 @@ def _do_mgmt(job, ev):
|
|
|
314
388
|
if op in ("install", "upgrade", "uninstall"):
|
|
315
389
|
res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
|
|
316
390
|
ok = res["ok"]
|
|
391
|
+
# 文件占用类失败(Windows 文件锁 EBUSY/EPERM)给人话结论并跳过 AI 修复:
|
|
392
|
+
# 修复智能体面对文件锁只会给出 taskkill 全杀 node 之类白名单必拒的危险
|
|
393
|
+
# 命令,白白烧一轮 300s 诊断(2026-09-18 dsh 同版本重装 EBUSY 案)
|
|
394
|
+
lock_hit = (not ok and op in ("install", "upgrade") and _file_lock_error(res))
|
|
395
|
+
if lock_hit:
|
|
396
|
+
summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
|
|
397
|
+
"请关闭占用该文件的程序后重试。完整输出见日志。")
|
|
398
|
+
else:
|
|
399
|
+
summary = ("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else "")
|
|
317
400
|
store.finish_step(run_id, step["n"],
|
|
318
401
|
"done" if ok else "failed",
|
|
319
|
-
summary=
|
|
402
|
+
summary=summary,
|
|
320
403
|
exit_code=res.get("exit_code"))
|
|
321
404
|
# AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
|
|
322
|
-
if not ok and op in ("install", "upgrade"):
|
|
405
|
+
if not ok and op in ("install", "upgrade") and not lock_hit:
|
|
323
406
|
ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
|
|
324
407
|
elif op == "smoke":
|
|
325
408
|
from . import runner as _r
|
|
@@ -383,6 +466,12 @@ def _do_selfupgrade(job):
|
|
|
383
466
|
summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
|
|
384
467
|
|
|
385
468
|
|
|
469
|
+
def _file_lock_error(res):
|
|
470
|
+
"""安装/升级失败输出是否为文件占用类错误(npm/pip 的 EBUSY/EPERM 文件锁)。"""
|
|
471
|
+
err = (res or {}).get("error") or ""
|
|
472
|
+
return "EBUSY" in err or "EPERM" in err
|
|
473
|
+
|
|
474
|
+
|
|
386
475
|
def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
387
476
|
"""安装失败后的 AI 诊断修复:诊断 → 白名单校验 → 执行 → 复检。"""
|
|
388
477
|
from . import catalog, manager, registry, router, runner, store
|
|
@@ -405,7 +494,8 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
405
494
|
]
|
|
406
495
|
prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
|
|
407
496
|
.replace("__LOG__", log_tail)
|
|
408
|
-
.replace("__ENV__", "\n".join(env_lines))
|
|
497
|
+
.replace("__ENV__", "\n".join(env_lines))
|
|
498
|
+
.replace("__ALLOW__", " / ".join(p.strip() for p in AI_REPAIR_ALLOW)))
|
|
409
499
|
step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
|
|
410
500
|
note="自动诊断修复")
|
|
411
501
|
res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
|
|
@@ -430,7 +520,9 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
430
520
|
cmd = str((data or {}).get("command") or "").strip()
|
|
431
521
|
safe = bool((data or {}).get("safe")) and _repair_command_allowed(cmd)
|
|
432
522
|
try:
|
|
433
|
-
|
|
523
|
+
# write_bytes 而非 write_text(...encode()):bytes 传入文本模式 write 会
|
|
524
|
+
# TypeError 且被下面的裸 except 吞掉,诊断三行从未落过日志(2026-09-18 修)
|
|
525
|
+
log_abs.write_bytes(
|
|
434
526
|
("\n[AI 诊断] %s\n[AI 建议] %s\n[白名单] %s\n" %
|
|
435
527
|
(diagnosis, cmd or "(无)", "通过" if safe else "不通过,拒绝自动执行")).encode("utf-8"))
|
|
436
528
|
except Exception:
|
|
@@ -445,6 +537,9 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
445
537
|
manager.detect_all(force=True)
|
|
446
538
|
with manager._LOCK:
|
|
447
539
|
manager._STATE["versions"].pop(entry["id"], None)
|
|
540
|
+
if fix["ok"]:
|
|
541
|
+
# 修复安装同样会改变版本结论,徽章缓存一并作废复检
|
|
542
|
+
manager.refresh_update_async(entry)
|
|
448
543
|
installed = manager.detect_entry(entry)["installed"]
|
|
449
544
|
store.finish_step(run_id, step["n"],
|
|
450
545
|
"done" if (fix["ok"] and installed) else "failed",
|
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),
|
|
@@ -1510,10 +1576,31 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
|
1510
1576
|
detect_all(force=True)
|
|
1511
1577
|
with _LOCK:
|
|
1512
1578
|
_STATE["versions"].pop(entry["id"], None)
|
|
1579
|
+
if res["ok"]:
|
|
1580
|
+
# 安装/升级成功即作废该条目的更新检查缓存并后台复检:否则 10 分钟 TTL
|
|
1581
|
+
# 内徽章仍显示「有新版本」,诱导同版本重装(重装易撞 EBUSY 文件锁,
|
|
1582
|
+
# 2026-09-18 dsh 案)。
|
|
1583
|
+
refresh_update_async(entry)
|
|
1513
1584
|
return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
|
|
1514
1585
|
"error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
|
|
1515
1586
|
|
|
1516
1587
|
|
|
1588
|
+
def refresh_update_async(entry):
|
|
1589
|
+
"""作废单条目的更新检查缓存,并后台立刻复检一次远端最新版本。
|
|
1590
|
+
|
|
1591
|
+
完成的安装/升级调用它,卡片徽章马上脱离旧结论(unknown → 几秒后
|
|
1592
|
+
「已是最新/有新版本」),不用等 10 分钟缓存过期。npm view 在后台线程
|
|
1593
|
+
跑,不阻塞 mgmt 任务收尾。
|
|
1594
|
+
"""
|
|
1595
|
+
with _LOCK:
|
|
1596
|
+
_UPDATE_CACHE.pop(entry["id"], None)
|
|
1597
|
+
try:
|
|
1598
|
+
threading.Thread(target=check_update, args=(entry,), kwargs={"force": True},
|
|
1599
|
+
name="update-recheck-%s" % entry.get("id"), daemon=True).start()
|
|
1600
|
+
except Exception:
|
|
1601
|
+
pass
|
|
1602
|
+
|
|
1603
|
+
|
|
1517
1604
|
# ---------------------------------------------------------------- 版本检查
|
|
1518
1605
|
|
|
1519
1606
|
_UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
|
|
@@ -1544,7 +1631,10 @@ def check_update(entry, force=False):
|
|
|
1544
1631
|
cmd = entry.get("install") or entry.get("upgrade") or ""
|
|
1545
1632
|
pkg = _npm_pkg_name(cmd)
|
|
1546
1633
|
if pkg:
|
|
1547
|
-
|
|
1634
|
+
# Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑
|
|
1635
|
+
npm_view = ["cmd", "/c", "npm", "view", pkg, "version"] if os.name == "nt" \
|
|
1636
|
+
else ["npm", "view", pkg, "version"]
|
|
1637
|
+
r = runner.run_process(argv=npm_view, timeout=90)
|
|
1548
1638
|
latest = ""
|
|
1549
1639
|
if r["ok"]:
|
|
1550
1640
|
for line in (r["stdout"] or "").splitlines():
|
|
@@ -1558,7 +1648,7 @@ def check_update(entry, force=False):
|
|
|
1558
1648
|
result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
|
|
1559
1649
|
if not result["updatable"]:
|
|
1560
1650
|
result["note"] = "已是最新版本"
|
|
1561
|
-
elif "winget" in cmd:
|
|
1651
|
+
elif os.name == "nt" and "winget" in cmd:
|
|
1562
1652
|
m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
|
|
1563
1653
|
wid = m.group(1) if m else None
|
|
1564
1654
|
if not wid:
|