codebee 0.1.105 → 0.1.106
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 +9 -0
- package/README.md +10 -7
- package/app/core/activity.py +36 -0
- package/app/core/builtin_agent.py +6 -1
- package/app/core/jobs.py +7 -0
- package/app/core/market_remote.py +19 -7
- package/app/core/mcp_client.py +102 -63
- package/app/core/pipeline.py +14 -6
- package/app/core/publish/auto.py +74 -3
- package/app/core/publish/ledger.py +5 -0
- package/app/core/publish/manager.py +5 -1
- package/app/core/runtime_lock.py +75 -0
- package/app/core/selfupdate.py +8 -0
- package/app/core/store.py +32 -2
- package/app/core/usage.py +31 -26
- package/app/core/webhooks.py +17 -13
- package/app/desktop_host.py +190 -0
- package/app/main.py +88 -4
- package/app/pet.py +1 -1
- package/app/ui/activity.css +34 -0
- package/app/ui/activity.js +199 -0
- package/app/ui/app.js +792 -64
- package/app/ui/hive3d.js +180 -61
- package/app/ui/i18n.js +130 -1
- package/app/ui/index.html +154 -75
- package/app/ui/style.css +583 -26
- package/package.json +1 -1
|
@@ -99,6 +99,7 @@ def _quality_release_guard(task, plat, action="publish", target_chapter=None,
|
|
|
99
99
|
decision["version_snapshot"] = quality_gate.local_version(task.get("workdir") or "")
|
|
100
100
|
decision["platform_snapshot"] = ledger.book_for(task.get("id"), plat) or {}
|
|
101
101
|
if verdict:
|
|
102
|
+
decision["reviewed_through"] = verdict.get("end_chapter") or 0
|
|
102
103
|
decision["quality_status"] = verdict.get("quality_status") or "continue_only"
|
|
103
104
|
decision["review_run_id"] = run.get("id")
|
|
104
105
|
return decision
|
|
@@ -720,7 +721,10 @@ def register_book(task_id, plat, title, book_id=""):
|
|
|
720
721
|
or old.get("title") != title[:120]):
|
|
721
722
|
ledger.update_book(task_id, plat, remote_total=None, remote_published=None,
|
|
722
723
|
remote_review=None, remote_rejected=None,
|
|
723
|
-
remote_synced_at=None, remote_error=None
|
|
724
|
+
remote_synced_at=None, remote_error=None,
|
|
725
|
+
remote_published_nos=None,
|
|
726
|
+
confirmed_published_through=None,
|
|
727
|
+
confirmed_published_at=None)
|
|
724
728
|
ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
|
|
725
729
|
"title": title[:120], "source": "manual"})
|
|
726
730
|
return True, ""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""One service per data directory, shared by CLI and desktop launchers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import atexit
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import IO, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServiceAlreadyRunning(RuntimeError):
|
|
11
|
+
"""Another process owns this data directory."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServiceLease:
|
|
15
|
+
"""OS-backed lock; a crashed process releases it automatically."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, directory: Path):
|
|
18
|
+
self.directory = Path(directory)
|
|
19
|
+
self._file: Optional[IO[bytes]] = None
|
|
20
|
+
|
|
21
|
+
def acquire(self) -> None:
|
|
22
|
+
if self._file is not None:
|
|
23
|
+
return
|
|
24
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
# Never unlink this file: replacing its inode defeats POSIX file locks.
|
|
26
|
+
handle = (self.directory / "service.lock").open("a+b")
|
|
27
|
+
try:
|
|
28
|
+
if os.name == "nt":
|
|
29
|
+
import msvcrt
|
|
30
|
+
handle.seek(0, os.SEEK_END)
|
|
31
|
+
if handle.tell() == 0:
|
|
32
|
+
handle.write(b"0")
|
|
33
|
+
handle.flush()
|
|
34
|
+
handle.seek(0)
|
|
35
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
36
|
+
else:
|
|
37
|
+
import fcntl
|
|
38
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
handle.close()
|
|
41
|
+
raise ServiceAlreadyRunning(
|
|
42
|
+
"CodeBee 已在使用此数据目录,请先退出已有服务:%s" % self.directory
|
|
43
|
+
) from exc
|
|
44
|
+
self._file = handle
|
|
45
|
+
|
|
46
|
+
def release(self) -> None:
|
|
47
|
+
handle, self._file = self._file, None
|
|
48
|
+
if handle is not None:
|
|
49
|
+
# Closing the handle releases both flock and Windows byte locks.
|
|
50
|
+
handle.close()
|
|
51
|
+
key = str(self.directory.resolve())
|
|
52
|
+
if _LEASES.get(key) is self:
|
|
53
|
+
_LEASES.pop(key, None)
|
|
54
|
+
|
|
55
|
+
def __enter__(self) -> "ServiceLease":
|
|
56
|
+
self.acquire()
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *args: object) -> None:
|
|
60
|
+
self.release()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_LEASES = {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def acquire_service(directory: Path) -> ServiceLease:
|
|
67
|
+
"""Keep a service lease until process exit; repeated startup calls reuse it."""
|
|
68
|
+
key = str(Path(directory).resolve())
|
|
69
|
+
lease = _LEASES.get(key)
|
|
70
|
+
if lease is None or lease._file is None:
|
|
71
|
+
lease = ServiceLease(Path(key))
|
|
72
|
+
lease.acquire()
|
|
73
|
+
_LEASES[key] = lease
|
|
74
|
+
atexit.register(lease.release)
|
|
75
|
+
return lease
|
package/app/core/selfupdate.py
CHANGED
|
@@ -50,6 +50,8 @@ def package_version():
|
|
|
50
50
|
|
|
51
51
|
def install_mode():
|
|
52
52
|
"""npm / repo / source / other(见模块 docstring)。"""
|
|
53
|
+
if os.environ.get("CODEBEE_DESKTOP") == "1":
|
|
54
|
+
return "desktop"
|
|
53
55
|
try:
|
|
54
56
|
parts = [p.lower() for p in paths.ROOT.resolve().parts]
|
|
55
57
|
except Exception:
|
|
@@ -138,6 +140,10 @@ def check(force=False):
|
|
|
138
140
|
cur = package_version()
|
|
139
141
|
out = {"mode": mode, "current": cur, "latest": "", "has_update": False,
|
|
140
142
|
"note": "", "notes": "", "whatsnew": changelog_section(cur)}
|
|
143
|
+
if mode == "desktop":
|
|
144
|
+
out["download_url"] = "https://github.com/Vercel-By-WXP/CodeBee/releases"
|
|
145
|
+
out["note"] = "桌面预览版:请从应用菜单打开下载页,退出应用后安装新版;任务和配置保留。"
|
|
146
|
+
return out
|
|
141
147
|
if mode != "npm":
|
|
142
148
|
out["note"] = ("开发仓库模式:请用 git pull 更新(自动升级会覆盖未提交的代码)"
|
|
143
149
|
if mode == "repo" else "非 npm 安装,无法自动更新")
|
|
@@ -352,6 +358,8 @@ def wait_port_before_bind(port):
|
|
|
352
358
|
def relaunch(port):
|
|
353
359
|
"""就地重启:拉起新实例(--wait-port 等新端口可 bind 时旧实例已自退)。
|
|
354
360
|
调用方发送完重启响应后应 self_quit()。port 经 int() 强校验,argv 全字面量。"""
|
|
361
|
+
if install_mode() == "desktop":
|
|
362
|
+
return False # Only the desktop shell may replace its owned sidecar.
|
|
355
363
|
port = int(port)
|
|
356
364
|
if port < 1 or port > 65535:
|
|
357
365
|
return False
|
package/app/core/store.py
CHANGED
|
@@ -2352,8 +2352,31 @@ def write_report(run_id, markdown):
|
|
|
2352
2352
|
return p
|
|
2353
2353
|
|
|
2354
2354
|
|
|
2355
|
+
def _builtin_live_log_text(run, rel_path):
|
|
2356
|
+
"""内置智能体步骤从不落 steps/*.log(实时流只写 run.json),日志文件缺席
|
|
2357
|
+
时用步骤记录拼一份可读日志兜底——否则所有读日志文件的面板(大屏下钻抽屉/
|
|
2358
|
+
蜂巢弹窗/日志抽屉)对 CodeBee 步骤永远「日志为空」,分不清真死还是在干活
|
|
2359
|
+
(2026-10-12 用户反馈)。运行中:活动+思考+正文流;已结束:思考+正文+摘要
|
|
2360
|
+
(stream/activity 收尾即清,finish_step 另落 output/summary)。"""
|
|
2361
|
+
step = next((s for s in (run.get("steps") or [])
|
|
2362
|
+
if s.get("log") == rel_path), None)
|
|
2363
|
+
if not step:
|
|
2364
|
+
return ""
|
|
2365
|
+
parts = []
|
|
2366
|
+
for act in (step.get("activity") or []):
|
|
2367
|
+
if act:
|
|
2368
|
+
parts.append("【活动】" + str(act))
|
|
2369
|
+
for key, tag in (("thinking", "【思考】"), ("stream", "【正文】"),
|
|
2370
|
+
("output", "【正文】"), ("summary", "【摘要】")):
|
|
2371
|
+
v = str(step.get(key) or "").strip()
|
|
2372
|
+
if v:
|
|
2373
|
+
parts.append(tag + " " + v)
|
|
2374
|
+
return "\n".join(parts)
|
|
2375
|
+
|
|
2376
|
+
|
|
2355
2377
|
def read_step_log(run_id, rel_path, tail=paths.LOG_TAIL_CHARS, pretty=False):
|
|
2356
2378
|
p = (paths.RUNS_DIR / run_id / rel_path).resolve()
|
|
2379
|
+
text = ""
|
|
2357
2380
|
try:
|
|
2358
2381
|
# 防目录穿越:必须落在本 run 目录内
|
|
2359
2382
|
if paths.RUNS_DIR.resolve() not in p.parents:
|
|
@@ -2369,6 +2392,13 @@ def read_step_log(run_id, rel_path, tail=paths.LOG_TAIL_CHARS, pretty=False):
|
|
|
2369
2392
|
text = runner.collapse_dup_lines(runner.clean_cli_text(text))
|
|
2370
2393
|
if pretty:
|
|
2371
2394
|
text = runner.pretty_cli_log(text)
|
|
2372
|
-
return text
|
|
2373
2395
|
except Exception:
|
|
2374
|
-
|
|
2396
|
+
text = ""
|
|
2397
|
+
if not (text or "").strip():
|
|
2398
|
+
# 文件缺席/为空的兜底:内置智能体只有 run.json 实时流;顺带惠及日志
|
|
2399
|
+
# 已被清理的老 run(终点摘要/思考还在步骤记录里)
|
|
2400
|
+
live = _builtin_live_log_text(get_run(run_id) or {}, str(rel_path))
|
|
2401
|
+
if live:
|
|
2402
|
+
text = live if len(live) <= tail else \
|
|
2403
|
+
"...(已截断)...\n" + live[-int(tail):]
|
|
2404
|
+
return text
|
package/app/core/usage.py
CHANGED
|
@@ -173,12 +173,14 @@ def _budget_caps_on():
|
|
|
173
173
|
|
|
174
174
|
_ALERT_PUSH = None # main.py 启动注入 notify.push_text——usage(L0) 不反向
|
|
175
175
|
# 依赖 notify(L1),静态图不成环(同 set_run_estimator 模式)
|
|
176
|
+
_ALERT_LOCK = threading.RLock()
|
|
176
177
|
|
|
177
178
|
|
|
178
179
|
def set_alert_push(fn):
|
|
179
180
|
"""注入花费预警的推送函数;未注入则预警静默跳过。"""
|
|
180
181
|
global _ALERT_PUSH
|
|
181
|
-
|
|
182
|
+
with _ALERT_LOCK:
|
|
183
|
+
_ALERT_PUSH = fn if callable(fn) else None
|
|
182
184
|
|
|
183
185
|
|
|
184
186
|
def _budget_alert_check(day):
|
|
@@ -201,31 +203,34 @@ def _budget_alert_check(day):
|
|
|
201
203
|
break
|
|
202
204
|
if not marks:
|
|
203
205
|
return
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
lines.append("%
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
206
|
+
with _ALERT_LOCK:
|
|
207
|
+
push = _ALERT_PUSH
|
|
208
|
+
if push is None:
|
|
209
|
+
return
|
|
210
|
+
sp = paths.DATA_DIR / "budget_alerts.json"
|
|
211
|
+
try:
|
|
212
|
+
st = json.loads(sp.read_text(encoding="utf-8"))
|
|
213
|
+
except Exception:
|
|
214
|
+
st = {}
|
|
215
|
+
if st.get("day") != day or not isinstance(st.get("done"), list):
|
|
216
|
+
st = {"day": day, "done": []}
|
|
217
|
+
done = st["done"]
|
|
218
|
+
new = [m for m in marks if ("%s%d" % (m[0], m[1])) not in done]
|
|
219
|
+
if not new:
|
|
220
|
+
return
|
|
221
|
+
lines = ["💸 CodeBee 花费预警"]
|
|
222
|
+
for scope, pct, spent, cap in new:
|
|
223
|
+
lines.append("%s花费 ¥%.2f,已达%s上限 ¥%.2f 的 %d%%" % (
|
|
224
|
+
"今日" if scope == "d" else "本月", spent,
|
|
225
|
+
"每日" if scope == "d" else "每月", cap, pct))
|
|
226
|
+
lines.append("(达 100% 后新步骤将停止,可在设置→编排设置→预算调整)")
|
|
227
|
+
if push("\n".join(lines)) is False:
|
|
228
|
+
return
|
|
229
|
+
st["done"] = done + ["%s%d" % (m[0], m[1]) for m in new]
|
|
230
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
tmp = sp.with_suffix(".tmp")
|
|
232
|
+
tmp.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8")
|
|
233
|
+
tmp.replace(sp)
|
|
229
234
|
except Exception:
|
|
230
235
|
pass
|
|
231
236
|
|
package/app/core/webhooks.py
CHANGED
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import sqlite3
|
|
9
9
|
import threading
|
|
10
10
|
import time
|
|
11
|
+
from contextlib import closing
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
from . import paths
|
|
@@ -62,17 +63,19 @@ def claim_delivery(delivery_id, *, now=None, ttl_seconds=86400):
|
|
|
62
63
|
db_path = _FILE.with_suffix(_FILE.suffix + ".sqlite3")
|
|
63
64
|
with _LOCK:
|
|
64
65
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
db.execute("
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
# SQLite's transaction context commits/rolls back but does not close.
|
|
67
|
+
with closing(sqlite3.connect(str(db_path), timeout=5)) as db:
|
|
68
|
+
with db:
|
|
69
|
+
db.execute("CREATE TABLE IF NOT EXISTS deliveries (delivery_id TEXT PRIMARY KEY, claimed_at REAL NOT NULL)")
|
|
70
|
+
cutoff = stamp - max(60, int(ttl_seconds))
|
|
71
|
+
db.execute("DELETE FROM deliveries WHERE claimed_at < ?", (cutoff,))
|
|
72
|
+
try:
|
|
73
|
+
db.execute("INSERT INTO deliveries(delivery_id, claimed_at) VALUES (?, ?)",
|
|
74
|
+
(delivery_id, stamp))
|
|
75
|
+
except sqlite3.IntegrityError:
|
|
76
|
+
return False, {"deduplicated": True, "delivery_id": delivery_id}
|
|
77
|
+
db.execute("DELETE FROM deliveries WHERE delivery_id NOT IN (SELECT delivery_id FROM deliveries ORDER BY claimed_at DESC LIMIT ?)",
|
|
78
|
+
(MAX_DELIVERIES,))
|
|
76
79
|
return True, {"deduplicated": False, "delivery_id": delivery_id}
|
|
77
80
|
|
|
78
81
|
|
|
@@ -83,7 +86,8 @@ def release_delivery(delivery_id):
|
|
|
83
86
|
db_path = _FILE.with_suffix(_FILE.suffix + ".sqlite3")
|
|
84
87
|
with _LOCK:
|
|
85
88
|
try:
|
|
86
|
-
with sqlite3.connect(str(db_path), timeout=5) as db:
|
|
87
|
-
db
|
|
89
|
+
with closing(sqlite3.connect(str(db_path), timeout=5)) as db:
|
|
90
|
+
with db:
|
|
91
|
+
db.execute("DELETE FROM deliveries WHERE delivery_id = ?", (delivery_id,))
|
|
88
92
|
except (OSError, sqlite3.Error):
|
|
89
93
|
pass
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Desktop sidecar: the existing engine with private lifecycle endpoints.
|
|
2
|
+
|
|
3
|
+
The desktop shell owns this process. Its secret stays in the main process and
|
|
4
|
+
sidecar environment, and is never exposed to the renderer or URL parameters.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
from typing import Callable, Dict, Tuple
|
|
18
|
+
|
|
19
|
+
from core import paths
|
|
20
|
+
from core.runtime_lock import ServiceAlreadyRunning, acquire_service
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DesktopPipe:
|
|
24
|
+
"""Native picker replies and parent liveness over the inherited stdin pipe."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, on_closed: Callable[[], None]):
|
|
27
|
+
self.on_closed = on_closed
|
|
28
|
+
self.condition = threading.Condition()
|
|
29
|
+
self.pending: Dict[str, object] = {}
|
|
30
|
+
self.closed = False
|
|
31
|
+
|
|
32
|
+
def listen(self) -> None:
|
|
33
|
+
try:
|
|
34
|
+
while True:
|
|
35
|
+
line = sys.stdin.buffer.readline(65537)
|
|
36
|
+
if not line:
|
|
37
|
+
break
|
|
38
|
+
if len(line) > 65536:
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
message = json.loads(line)
|
|
42
|
+
if not isinstance(message, dict):
|
|
43
|
+
continue
|
|
44
|
+
request_id = message.get("id")
|
|
45
|
+
if not isinstance(request_id, str):
|
|
46
|
+
continue
|
|
47
|
+
with self.condition:
|
|
48
|
+
if request_id in self.pending:
|
|
49
|
+
self.pending[request_id] = str(message.get("path") or "")
|
|
50
|
+
self.condition.notify_all()
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
continue
|
|
53
|
+
finally:
|
|
54
|
+
with self.condition:
|
|
55
|
+
self.closed = True
|
|
56
|
+
self.condition.notify_all()
|
|
57
|
+
self.on_closed()
|
|
58
|
+
|
|
59
|
+
def pick(self, mode: str, initial: str = "", title: str = "") -> Tuple[str, str, bool]:
|
|
60
|
+
request_id = uuid.uuid4().hex
|
|
61
|
+
with self.condition:
|
|
62
|
+
if self.closed:
|
|
63
|
+
return "", "桌面窗口已退出", True
|
|
64
|
+
self.pending[request_id] = None
|
|
65
|
+
print("CODEBEE_DESKTOP_PICK " + json.dumps({"id": request_id,
|
|
66
|
+
"mode": mode, "initial": initial, "title": title}, ensure_ascii=True),
|
|
67
|
+
flush=True)
|
|
68
|
+
self.condition.wait_for(
|
|
69
|
+
lambda: self.closed or self.pending[request_id] is not None, timeout=600)
|
|
70
|
+
result = self.pending.pop(request_id)
|
|
71
|
+
if result is None:
|
|
72
|
+
return "", "原生对话框未返回", True
|
|
73
|
+
return str(result), "", False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
parser = argparse.ArgumentParser(description="CodeBee desktop sidecar")
|
|
78
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
79
|
+
args = parser.parse_args()
|
|
80
|
+
secret = os.environ.get("CODEBEE_DESKTOP_SECRET", "")
|
|
81
|
+
if len(secret) < 32:
|
|
82
|
+
raise SystemExit("Desktop launcher secret is missing")
|
|
83
|
+
if not 0 <= args.port <= 65535:
|
|
84
|
+
raise SystemExit("Invalid desktop port")
|
|
85
|
+
os.environ["CODEBEE_DESKTOP"] = "1"
|
|
86
|
+
try:
|
|
87
|
+
acquire_service(paths.DATA_DIR)
|
|
88
|
+
except ServiceAlreadyRunning as exc:
|
|
89
|
+
raise SystemExit(str(exc))
|
|
90
|
+
|
|
91
|
+
import main as engine
|
|
92
|
+
from core import jobs, store
|
|
93
|
+
|
|
94
|
+
shutdown_started = threading.Event()
|
|
95
|
+
|
|
96
|
+
def shutdown(force: bool = False) -> None:
|
|
97
|
+
if shutdown_started.is_set():
|
|
98
|
+
return
|
|
99
|
+
shutdown_started.set()
|
|
100
|
+
if force:
|
|
101
|
+
jobs.begin_shutdown_drain()
|
|
102
|
+
jobs.cancel_all()
|
|
103
|
+
# Runner cancellation kills child trees first, then drains stdout/stderr
|
|
104
|
+
# for up to ten seconds. Keep the sidecar alive long enough for that
|
|
105
|
+
# cleanup so force quit does not leave a CLI child behind.
|
|
106
|
+
deadline = time.monotonic() + 20
|
|
107
|
+
while time.monotonic() < deadline:
|
|
108
|
+
info = jobs.workers_info()
|
|
109
|
+
if not (info["alive"] or info["chat_alive"]):
|
|
110
|
+
break
|
|
111
|
+
time.sleep(0.1)
|
|
112
|
+
# The shell owns the sidecar process. Once the response is sent and
|
|
113
|
+
# workers have drained, exit directly; calling HTTPServer.shutdown()
|
|
114
|
+
# from an auxiliary thread can wait behind a keep-alive request.
|
|
115
|
+
os._exit(0)
|
|
116
|
+
|
|
117
|
+
pipe = DesktopPipe(lambda: shutdown(force=True))
|
|
118
|
+
|
|
119
|
+
class DesktopHandler(engine.Handler):
|
|
120
|
+
def _desktop_authed(self) -> bool:
|
|
121
|
+
return (
|
|
122
|
+
self.client_address[0] == "127.0.0.1"
|
|
123
|
+
and not self.headers.get("X-Forwarded-For")
|
|
124
|
+
and not self.headers.get("CF-Connecting-IP")
|
|
125
|
+
and hmac.compare_digest(
|
|
126
|
+
self.headers.get("X-CodeBee-Desktop", ""), secret)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _authed(self):
|
|
130
|
+
# The renderer receives the secret through the private loopback
|
|
131
|
+
# request interceptor; it never appears in the URL or page DOM.
|
|
132
|
+
return self._desktop_authed() or super()._authed()
|
|
133
|
+
|
|
134
|
+
def _route_get(self):
|
|
135
|
+
if urlparse(self.path).path != "/api/desktop/status":
|
|
136
|
+
return super()._route_get()
|
|
137
|
+
if not self._desktop_authed():
|
|
138
|
+
return self._json(403, {"error": "desktop authorization required"})
|
|
139
|
+
events = [{key: run.get(key) for key in
|
|
140
|
+
("id", "title", "task_id", "status", "ended_at")}
|
|
141
|
+
for run in store.list_runs(limit=50)]
|
|
142
|
+
return self._json(200, {"pid": os.getpid(), "jobs": jobs.workers_info(),
|
|
143
|
+
"events": events})
|
|
144
|
+
|
|
145
|
+
def _route_post(self):
|
|
146
|
+
if urlparse(self.path).path != "/api/desktop/shutdown":
|
|
147
|
+
return super()._route_post()
|
|
148
|
+
if not self._desktop_authed():
|
|
149
|
+
return self._json(403, {"error": "desktop authorization required"})
|
|
150
|
+
body = self._body()
|
|
151
|
+
if body.get("force") is not True and not jobs.begin_restart_drain():
|
|
152
|
+
return self._json(409, {"error": "任务仍在运行,请先完成或确认停止任务"})
|
|
153
|
+
if body.get("force") is True:
|
|
154
|
+
jobs.begin_shutdown_drain()
|
|
155
|
+
self._json(200, {"ok": True})
|
|
156
|
+
threading.Thread(target=shutdown, args=(body.get("force") is True,),
|
|
157
|
+
daemon=True).start()
|
|
158
|
+
|
|
159
|
+
class DesktopServer(engine.ThreadedServer):
|
|
160
|
+
daemon_threads = True
|
|
161
|
+
|
|
162
|
+
def serve_forever(self, *a, **kw):
|
|
163
|
+
print("CODEBEE_DESKTOP_READY " + json.dumps(
|
|
164
|
+
{"port": self.server_address[1], "pid": os.getpid()}), flush=True)
|
|
165
|
+
threading.Thread(target=pipe.listen, daemon=True).start()
|
|
166
|
+
return super().serve_forever(*a, **kw)
|
|
167
|
+
|
|
168
|
+
# Bind before recovery/schedulers start. Never enter the CLI's stale-port
|
|
169
|
+
# cleanup path: a desktop launch must not terminate a running CLI service.
|
|
170
|
+
try:
|
|
171
|
+
server = DesktopServer(("127.0.0.1", args.port), DesktopHandler)
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
raise SystemExit("CodeBee 端口已被占用,请退出已有服务后重试:%s" % exc)
|
|
174
|
+
port = server.server_address[1]
|
|
175
|
+
engine.ThreadedServer = lambda *a, **kw: server
|
|
176
|
+
engine._start_pet_keeper = lambda *a, **kw: None
|
|
177
|
+
engine.pick_dialog.ask_directory = lambda initial="", title="选择文件夹": pipe.pick(
|
|
178
|
+
"directory", initial, title)
|
|
179
|
+
engine.pick_dialog.ask_file = lambda initial="", title="选择文件": pipe.pick(
|
|
180
|
+
"file", initial, title)
|
|
181
|
+
sys.argv = [str(paths.APP_DIR / "main.py"), "--host", "127.0.0.1",
|
|
182
|
+
"--port", str(port), "--no-browser", "--no-public-tunnel"]
|
|
183
|
+
try:
|
|
184
|
+
engine.main()
|
|
185
|
+
finally:
|
|
186
|
+
server.server_close()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
package/app/main.py
CHANGED
|
@@ -31,7 +31,7 @@ if os.environ.get("TUTTI_ISOLATE_HOME", "").strip() == "1":
|
|
|
31
31
|
os.environ["USERPROFILE"] = _fake_home
|
|
32
32
|
os.environ["HOME"] = _fake_home
|
|
33
33
|
|
|
34
|
-
from core import automation, board, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
|
|
34
|
+
from core import activity, automation, board, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
|
|
35
35
|
from core import paths
|
|
36
36
|
from core import health
|
|
37
37
|
from core import usage as usage_ledger
|
|
@@ -255,6 +255,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
255
255
|
return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
|
|
256
256
|
if path == "/api/state":
|
|
257
257
|
return self._json(200, _state_payload(self._client_id()))
|
|
258
|
+
if path == "/api/goals":
|
|
259
|
+
from core import goal_service
|
|
260
|
+
return self._json(200, {"current": goal_service.current(),
|
|
261
|
+
"goals": goal_service.list_goals()})
|
|
258
262
|
if path == "/api/openapi.json":
|
|
259
263
|
from core import openapi
|
|
260
264
|
return self._json(200, openapi.document())
|
|
@@ -916,6 +920,75 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
916
920
|
return deny
|
|
917
921
|
if path == "/api/tasks":
|
|
918
922
|
return self._api_create_task()
|
|
923
|
+
if path == "/api/goals":
|
|
924
|
+
from core import goal_service
|
|
925
|
+
body = self._body() or {}
|
|
926
|
+
title = str(body.get("title") or "").strip()
|
|
927
|
+
description = str(body.get("description") or "").strip()
|
|
928
|
+
if not title:
|
|
929
|
+
return self._json(400, {"error": "Goal 标题不能为空"})
|
|
930
|
+
if len(title) > 160 or len(description) > 4000:
|
|
931
|
+
return self._json(400, {"error": "Goal 标题最多 160 字,描述最多 4000 字"})
|
|
932
|
+
try:
|
|
933
|
+
goal = goal_service.create(title, description)
|
|
934
|
+
except goal_service.GoalExistsError as exc:
|
|
935
|
+
return self._json(409, {"error": str(exc), "current": goal_service.current()})
|
|
936
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
937
|
+
return self._json(400, {"error": str(exc)})
|
|
938
|
+
return self._json(200, {"goal": goal})
|
|
939
|
+
m = re.match(r"^/api/goals/([^/]+)$", path)
|
|
940
|
+
if m:
|
|
941
|
+
from core import goal_service
|
|
942
|
+
body = self._body() or {}
|
|
943
|
+
revision = body.get("revision")
|
|
944
|
+
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
|
|
945
|
+
return self._json(400, {"error": "revision 必须是正整数"})
|
|
946
|
+
changes = {}
|
|
947
|
+
for key in ("title", "description", "phase", "rounds_max"):
|
|
948
|
+
if key in body:
|
|
949
|
+
changes[key] = body[key]
|
|
950
|
+
if not changes:
|
|
951
|
+
return self._json(400, {"error": "没有可更新的字段"})
|
|
952
|
+
if "title" in changes:
|
|
953
|
+
changes["title"] = str(changes["title"] or "").strip()
|
|
954
|
+
if not changes["title"] or len(changes["title"]) > 160:
|
|
955
|
+
return self._json(400, {"error": "Goal 标题不能为空且最多 160 字"})
|
|
956
|
+
if "description" in changes:
|
|
957
|
+
changes["description"] = str(changes["description"] or "").strip()
|
|
958
|
+
if len(changes["description"]) > 4000:
|
|
959
|
+
return self._json(400, {"error": "Goal 描述最多 4000 字"})
|
|
960
|
+
try:
|
|
961
|
+
goal = goal_service.update(unquote(m.group(1)), revision, **changes)
|
|
962
|
+
except goal_service.GoalNotFoundError:
|
|
963
|
+
return self._json(404, {"error": "Goal 不存在"})
|
|
964
|
+
except goal_service.StaleRevisionError as exc:
|
|
965
|
+
return self._json(409, {"error": str(exc), "current": goal_service.current()})
|
|
966
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
967
|
+
return self._json(400, {"error": str(exc)})
|
|
968
|
+
return self._json(200, {"goal": goal})
|
|
969
|
+
m = re.match(r"^/api/goals/([^/]+)/round$", path)
|
|
970
|
+
if m:
|
|
971
|
+
from core import goal_service
|
|
972
|
+
body = self._body() or {}
|
|
973
|
+
revision = body.get("revision")
|
|
974
|
+
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
|
|
975
|
+
return self._json(400, {"error": "revision 必须是正整数"})
|
|
976
|
+
goal = goal_service.get(unquote(m.group(1)))
|
|
977
|
+
if not goal:
|
|
978
|
+
return self._json(404, {"error": "Goal 不存在"})
|
|
979
|
+
if goal.get("phase") != "active":
|
|
980
|
+
return self._json(409, {"error": "只有进行中的 Goal 才能开始下一轮", "goal": goal})
|
|
981
|
+
if int(goal.get("rounds_started") or 0) >= int(goal.get("rounds_max") or 1):
|
|
982
|
+
return self._json(409, {"error": "Goal 已达到轮数上限", "goal": goal})
|
|
983
|
+
try:
|
|
984
|
+
goal = goal_service.increment_rounds(unquote(m.group(1)), revision)
|
|
985
|
+
except goal_service.GoalNotFoundError:
|
|
986
|
+
return self._json(404, {"error": "Goal 不存在"})
|
|
987
|
+
except goal_service.StaleRevisionError as exc:
|
|
988
|
+
return self._json(409, {"error": str(exc), "current": goal_service.current()})
|
|
989
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
990
|
+
return self._json(400, {"error": str(exc)})
|
|
991
|
+
return self._json(200, {"goal": goal})
|
|
919
992
|
if path == "/api/backend-profiles":
|
|
920
993
|
from core import backend_profiles
|
|
921
994
|
try:
|
|
@@ -3427,10 +3500,12 @@ def _state_payload(client_id="", ver=None):
|
|
|
3427
3500
|
ver = store.state_version()
|
|
3428
3501
|
runs = store.list_runs(40)
|
|
3429
3502
|
latest = store.latest_run_by_task()
|
|
3503
|
+
tasks = store.list_tasks(archived=False)
|
|
3430
3504
|
return {
|
|
3431
3505
|
"v": ver,
|
|
3432
3506
|
"agents": agents,
|
|
3433
|
-
"tasks":
|
|
3507
|
+
"tasks": tasks,
|
|
3508
|
+
"task_attention": activity.task_attention(tasks, latest),
|
|
3434
3509
|
# 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
|
|
3435
3510
|
"archived_tasks": [_state_task_summary(t) for t in store.list_tasks(archived=True)],
|
|
3436
3511
|
# 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
|
|
@@ -3461,6 +3536,7 @@ def _state_run_summary(run):
|
|
|
3461
3536
|
"auto_resumes", "resume_enqueue_at", "cost_usd", "tokens")
|
|
3462
3537
|
summary = {key: run[key] for key in fields if key in run}
|
|
3463
3538
|
summary["step_count"] = len(run.get("steps") or [])
|
|
3539
|
+
summary.update(activity.run_activity(run))
|
|
3464
3540
|
messages = run.get("messages") or []
|
|
3465
3541
|
summary["message_count"] = len(messages)
|
|
3466
3542
|
summary["pending_message_count"] = sum(1 for message in messages if not message.get("consumed"))
|
|
@@ -3554,6 +3630,13 @@ def main():
|
|
|
3554
3630
|
help="自更新重启用:先等旧实例释放端口再启动(Windows 双 LISTEN 防护)")
|
|
3555
3631
|
args = parser.parse_args()
|
|
3556
3632
|
|
|
3633
|
+
# CLI and desktop must never recover/run the same task store concurrently.
|
|
3634
|
+
from core.runtime_lock import ServiceAlreadyRunning, acquire_service
|
|
3635
|
+
try:
|
|
3636
|
+
acquire_service(paths.DATA_DIR)
|
|
3637
|
+
except ServiceAlreadyRunning as exc:
|
|
3638
|
+
raise SystemExit(str(exc))
|
|
3639
|
+
|
|
3557
3640
|
# 测试实例禁止使用默认端口 8765(2026-09-23 实案:代理测试忘带 --port,
|
|
3558
3641
|
# 启动清场把真实服务当旧实例杀掉后鸠占鹊巢)。测试请显式指定独立端口。
|
|
3559
3642
|
try:
|
|
@@ -3590,6 +3673,8 @@ def main():
|
|
|
3590
3673
|
print("[CodeBee] v%s" % (_ver or "?"), flush=True)
|
|
3591
3674
|
_step("正在准备数据目录…")
|
|
3592
3675
|
paths.ensure_dirs()
|
|
3676
|
+
from core import goal_service
|
|
3677
|
+
goal_service.init()
|
|
3593
3678
|
from core import backend_profiles as _backend_profiles
|
|
3594
3679
|
from core import command_auth as _command_auth
|
|
3595
3680
|
_backend_profiles.init()
|
|
@@ -3822,9 +3907,8 @@ def main():
|
|
|
3822
3907
|
% (remote.PUBLIC_URL, tok))
|
|
3823
3908
|
elif not args.no_public_tunnel and args.host != "127.0.0.1":
|
|
3824
3909
|
if remote.has_local_creds():
|
|
3825
|
-
extra = ",或 start-public.bat" if os.name == "nt" else ""
|
|
3826
3910
|
print("[CodeBee] (检测到已有 Cloudflare 隧道凭据:临时隧道在此类机器上不可用,"
|
|
3827
|
-
"请用 --public-url
|
|
3911
|
+
"请用 --public-url 配固定域名)")
|
|
3828
3912
|
elif remote.start_quick_tunnel(args.port, _announce_public):
|
|
3829
3913
|
print("[CodeBee] 正在建立 Cloudflare 快速隧道(公网地址几秒后打印;"
|
|
3830
3914
|
"若打不开说明当前网络不支持,可改用固定域名或 Tailscale)…")
|
package/app/pet.py
CHANGED
|
@@ -433,7 +433,7 @@ def global_lock_held():
|
|
|
433
433
|
|
|
434
434
|
|
|
435
435
|
# 浏览器进程白名单:找已打开的 CodeBee 窗口时只认这些主进程的顶层窗口,
|
|
436
|
-
# 避免把桌宠自身(标题也是 CodeBee)或 start-
|
|
436
|
+
# 避免把桌宠自身(标题也是 CodeBee)或 start-service.bat 起的「CodeBee」
|
|
437
437
|
# 控制台窗口误当成浏览器置前。
|
|
438
438
|
_BROWSER_EXES = {
|
|
439
439
|
"msedge.exe", "chrome.exe", "chromium.exe", "firefox.exe",
|