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.
@@ -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 = store.create_task(payload)
303
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
304
- store.update_task_status(task["id"], "queued")
305
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
306
- return run["id"]
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):
@@ -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
 
@@ -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):
@@ -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
- CREATE_NO_WINDOW = 0x08000000
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
- 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"):
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
- r = subprocess.run(["cmd", "/c", cli, "--version"], capture_output=True,
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
- subprocess.Popen(["cmd", "/c", spawn_cmd], cwd=str(paths.ROOT), env=env,
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
- r = runner.run_process(argv=["cmd", "/c", "npm", "view", pkg, "version"], timeout=90)
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: