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.
@@ -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
@@ -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
- return ""
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
- _ALERT_PUSH = fn if callable(fn) else None
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
- sp = paths.DATA_DIR / "budget_alerts.json"
205
- try:
206
- st = json.loads(sp.read_text(encoding="utf-8"))
207
- except Exception:
208
- st = {}
209
- if st.get("day") != day or not isinstance(st.get("done"), list):
210
- st = {"day": day, "done": []}
211
- done = st["done"]
212
- new = [m for m in marks if ("%s%d" % (m[0], m[1])) not in done]
213
- if not new:
214
- return
215
- st["done"] = done + ["%s%d" % (m[0], m[1]) for m in new]
216
- sp.parent.mkdir(parents=True, exist_ok=True)
217
- tmp = sp.with_suffix(".tmp")
218
- tmp.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8")
219
- tmp.replace(sp)
220
- if not _ALERT_PUSH:
221
- return
222
- lines = ["💸 CodeBee 花费预警"]
223
- for scope, pct, spent, cap in new:
224
- lines.append("%s花费 ¥%.2f,已达%s上限 ¥%.2f 的 %d%%" % (
225
- "今日" if scope == "d" else "本月", spent,
226
- "每日" if scope == "d" else "每月", cap, pct))
227
- lines.append("(达 100% 后新步骤将停止,可在设置→编排设置→预算调整)")
228
- _ALERT_PUSH("\n".join(lines))
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
 
@@ -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
- with sqlite3.connect(str(db_path), timeout=5) as db:
66
- db.execute("CREATE TABLE IF NOT EXISTS deliveries (delivery_id TEXT PRIMARY KEY, claimed_at REAL NOT NULL)")
67
- cutoff = stamp - max(60, int(ttl_seconds))
68
- db.execute("DELETE FROM deliveries WHERE claimed_at < ?", (cutoff,))
69
- try:
70
- db.execute("INSERT INTO deliveries(delivery_id, claimed_at) VALUES (?, ?)",
71
- (delivery_id, stamp))
72
- except sqlite3.IntegrityError:
73
- return False, {"deduplicated": True, "delivery_id": delivery_id}
74
- db.execute("DELETE FROM deliveries WHERE delivery_id NOT IN (SELECT delivery_id FROM deliveries ORDER BY claimed_at DESC LIMIT ?)",
75
- (MAX_DELIVERIES,))
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.execute("DELETE FROM deliveries WHERE delivery_id = ?", (delivery_id,))
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": store.list_tasks(archived=False),
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 配固定域名%s)" % extra)
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-public.bat 起的「CodeBee」
436
+ # 避免把桌宠自身(标题也是 CodeBee)或 start-service.bat 起的「CodeBee」
437
437
  # 控制台窗口误当成浏览器置前。
438
438
  _BROWSER_EXES = {
439
439
  "msedge.exe", "chrome.exe", "chromium.exe", "firefox.exe",