codebee 0.1.22 → 0.1.24

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.
@@ -38,6 +38,7 @@ def _user_pack_dir():
38
38
 
39
39
  MAX_INJECT_CHARS = 9000 # 单次注入上限(防止提示词爆炸;七猫+番茄双平台包并存后上调)
40
40
  MAX_LESSONS_INJECT = 8 # 注入的自动教训条数上限
41
+ WILDCARD_PACK_CHAR_CAP = 2400 # wildcard(scope=*)包单包注入预算:市场通配技能动辄数万字,全文注入会挤掉项目教训
41
42
 
42
43
  # 自动教训的问题分类:闭集枚举,对齐评审维度。沉淀时由复盘官归类(兜底路径按评审
43
44
  # 维度关键词映射),UI 据此分类过滤查看。刻意保持小而稳,避免类别爆炸让过滤失去意义。
@@ -390,14 +391,14 @@ def relevance_top(lessons, task, limit):
390
391
  if not probe:
391
392
  return lessons[:limit]
392
393
 
393
- # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
394
- def rank(x):
395
- grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
396
- overlap = -len(probe & grams)
397
- lid = x.get("id") or ""
398
- return (overlap, -int(x.get("hits") or 0), lid)
399
-
400
- return sorted(lessons, key=rank)[:limit]
394
+ # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
395
+ def rank(x):
396
+ grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
397
+ overlap = -len(probe & grams)
398
+ lid = x.get("id") or ""
399
+ return (overlap, -int(x.get("hits") or 0), lid)
400
+
401
+ return sorted(lessons, key=rank)[:limit]
401
402
 
402
403
 
403
404
  def block_for(task, scope_override=None, *, stable_order=False):
@@ -408,9 +409,14 @@ def block_for(task, scope_override=None, *, stable_order=False):
408
409
  stable_order=True(docs/migration/07-token-cost.md T1.2'):教训按 id 排序
409
410
  而非 hits——hits 在任务中途变化会让技能块字节级不稳定,打碎供应商的
410
411
  前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
412
+
413
+ 预算纪律(2026-09-21 巡检实锤引入):wildcard(scope=*)包动辄数万字,
414
+ 39 个全文注入会先把 9000 字全局上限吃光,项目教训排在末尾被整段截掉。
415
+ 两道预算:①wildcard 包单包限额(定向命中的包不受限);②教训保底——
416
+ 包区最多吃到「全局上限 − 教训长度」,教训永远完整注入。
411
417
  """
412
418
  scope = scope_override or task.get("type") or "*"
413
- parts, used, lesson_ids = [], [], []
419
+ parts, used, lesson_ids = [], [], []
414
420
 
415
421
  for p in all_packs():
416
422
  if scope not in p["scopes"] and "*" not in p["scopes"]:
@@ -420,6 +426,9 @@ def block_for(task, scope_override=None, *, stable_order=False):
420
426
  txt = pack_text(p).strip()
421
427
  if not txt and not p.get("persona"):
422
428
  continue
429
+ # wildcard 包(仅靠 * 命中,非定向)单包限预算;定向命中不限,走全局
430
+ if scope not in p["scopes"] and len(txt) > WILDCARD_PACK_CHAR_CAP:
431
+ txt = txt[:WILDCARD_PACK_CHAR_CAP] + "\n…(本包超出通配注入预算已截断,完整内容见技能库)"
423
432
  # 3B:persona 独立成块(角色设定与规范正文分开,模型更易区分 obey 层级)
424
433
  if p.get("persona"):
425
434
  parts.append("### 【角色设定:%s】\n%s" % (p["name"], str(p["persona"]).strip()))
@@ -429,23 +438,32 @@ def block_for(task, scope_override=None, *, stable_order=False):
429
438
 
430
439
  lessons = relevance_top(list_lessons(scope, only_enabled=True), task,
431
440
  MAX_LESSONS_INJECT)
441
+ lesson_part = ""
432
442
  if lessons:
433
443
  if stable_order:
434
444
  lessons.sort(key=lambda x: x.get("id") or "")
435
445
  lines = []
436
- for x in lessons:
437
- lines.append("- **%s**:%s" % (x["title"], x["content"]))
438
- used.append(x["id"])
439
- lesson_ids.append(x["id"])
440
- parts.append("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n" + "\n".join(lines))
441
-
442
- if not parts:
446
+ for x in lessons:
447
+ lines.append("- **%s**:%s" % (x["title"], x["content"]))
448
+ used.append(x["id"])
449
+ lesson_ids.append(x["id"])
450
+ lesson_part = ("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n"
451
+ + "\n".join(lines))
452
+
453
+ if not parts and not lesson_part:
443
454
  return "", []
444
- text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
455
+
456
+ header = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n"
457
+ budget = max(600, MAX_INJECT_CHARS - len(lesson_part))
458
+ body = "\n\n".join(parts)
459
+ if len(body) > budget:
460
+ body = body[:budget] + "\n…(包区已按预算截断,优先保住项目教训)"
461
+ body = (body + "\n\n" + lesson_part) if (body and lesson_part) else (body or lesson_part)
462
+ text = header + body
445
463
  if len(text) > MAX_INJECT_CHARS:
446
464
  text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
447
- if lesson_ids:
448
- bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
465
+ if lesson_ids:
466
+ bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
449
467
  return text, used
450
468
 
451
469
 
package/app/core/store.py CHANGED
@@ -246,10 +246,8 @@ def create_task(payload):
246
246
  raise ValueError("附件落盘失败: %s" % e)
247
247
  if items:
248
248
  task["attachments"] = items
249
- blk = att_mod.context_block(items)
250
- # 复制清单场景下 context 已含附件块(随旧任务沿用),别重复追加
251
- if blk and "## 附件材料" not in task["context"]:
252
- task["context"] = (task["context"] + blk).strip()
249
+ task["context"] = att_mod.merge_context(
250
+ task["context"], items, workdir=str(wd))
253
251
  with LOCK:
254
252
  _TASKS[task["id"]] = task
255
253
  _save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
@@ -2,6 +2,8 @@
2
2
  """任务编译器:把用户任务和预置流程编译成统一、可审计的运行规格。"""
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Mapping
6
+
5
7
  from . import dispatch, flows
6
8
 
7
9
  SCHEMA_VERSION = 1
@@ -41,7 +43,7 @@ def _capabilities(task, dimension, engine):
41
43
 
42
44
  def compile_task(task):
43
45
  """返回统一任务规格;输入缺失或字段异常时保持可编排的安全兜底。"""
44
- raw = dict(task or {})
46
+ raw = dict(task) if isinstance(task, Mapping) else {}
45
47
  ttype = str(raw.get("type") or "direct").strip().lower()
46
48
  flow = flows.get_flow(ttype) or {}
47
49
  engine = str(raw.get("engine") or flow.get("engine") or
@@ -53,6 +55,8 @@ def compile_task(task):
53
55
  rubric = raw.get("rubric") or flow.get("rubric") or []
54
56
  if isinstance(rubric, str):
55
57
  rubric = [x.strip() for x in rubric.replace(",", ",").split(",") if x.strip()]
58
+ elif not isinstance(rubric, (list, tuple)):
59
+ rubric = flow.get("rubric") or []
56
60
  rubric = [str(x).strip() for x in rubric if str(x).strip()][:8]
57
61
  deliverable = str(raw.get("manuscript") or flow.get("manuscript") or "").strip()
58
62
  serial = raw.get("serial") if isinstance(raw.get("serial"), dict) else None
package/app/core/usage.py CHANGED
@@ -10,8 +10,9 @@
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
- import json
14
- import threading
13
+ import json
14
+ import math
15
+ import threading
15
16
  import time
16
17
 
17
18
  from . import paths
@@ -32,8 +33,12 @@ FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent"
32
33
  "input", "output", "cached", "reasoning", "total", "cost_usd", "source")
33
34
 
34
35
 
35
- def _month_file(day):
36
- return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
36
+ def _month_file(day):
37
+ return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
38
+
39
+
40
+ def _quality_file(day):
41
+ return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
37
42
 
38
43
 
39
44
  def _parse_int(v):
@@ -93,10 +98,13 @@ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
93
98
  rec.update({"input": inp, "output": out, "cached": cach,
94
99
  "reasoning": reas, "total": total})
95
100
  line = json.dumps(rec, ensure_ascii=False)
96
- with LOCK:
97
- paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
98
- with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
99
- f.write(line + "\n")
101
+ with LOCK:
102
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
103
+ with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
104
+ f.write(line + "\n")
105
+ _ROUTING_CACHE["data"].clear()
106
+ _ROUTING_RECORDS_CACHE.clear()
107
+ _HOURLY_CACHE["ts"] = 0.0
100
108
  except Exception:
101
109
  pass
102
110
 
@@ -196,8 +204,11 @@ def _tool_of(agent_id):
196
204
  return a or "unknown"
197
205
 
198
206
 
199
- _HOURLY_CACHE = {"ts": 0.0, "val": {}}
200
- _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
207
+ _HOURLY_CACHE = {"ts": 0.0, "val": {}}
208
+ _HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
209
+ _ROUTING_CACHE = {"data": {}}
210
+ _ROUTING_RECORDS_CACHE = {}
211
+ _ROUTING_TTL = 15.0
201
212
 
202
213
 
203
214
  def agent_tokens_recent(agent, hours=1):
@@ -224,7 +235,7 @@ def agent_tokens_recent(agent, hours=1):
224
235
  return 0
225
236
 
226
237
 
227
- def _iter_records(days):
238
+ def _iter_records(days):
228
239
  """按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
229
240
  out = []
230
241
  try:
@@ -232,11 +243,14 @@ def _iter_records(days):
232
243
  except Exception:
233
244
  return out
234
245
  since_day = ""
235
- if days:
236
- import datetime
237
- d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
238
- since_day = d
239
- for p in files:
246
+ if days:
247
+ import datetime
248
+ d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
249
+ since_day = d
250
+ since_month = since_day[:7].replace("-", "") if since_day else ""
251
+ for p in files:
252
+ if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
253
+ continue
240
254
  try:
241
255
  for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
242
256
  line = line.strip()
@@ -253,14 +267,202 @@ def _iter_records(days):
253
267
  out.append(r)
254
268
  except Exception:
255
269
  continue
256
- return out
257
-
258
-
259
- def _num(r, key):
260
- return _parse_int(r.get(key))
261
-
262
-
263
- def _group(records, key):
270
+ return out
271
+
272
+
273
+ def _iter_quality_records(days):
274
+ """读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
275
+ out = []
276
+ try:
277
+ files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
278
+ if paths.USAGE_DIR.is_dir() else []
279
+ except Exception:
280
+ return out
281
+ since_day = ""
282
+ if days:
283
+ import datetime
284
+ since_day = (datetime.date.today()
285
+ - datetime.timedelta(days=int(days) - 1)).isoformat()
286
+ since_month = since_day[:7].replace("-", "") if since_day else ""
287
+ for path in files:
288
+ if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
289
+ continue
290
+ try:
291
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
292
+ try:
293
+ row = json.loads(line)
294
+ except Exception:
295
+ continue
296
+ if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
297
+ out.append(row)
298
+ except Exception:
299
+ continue
300
+ return out
301
+
302
+
303
+ def _routing_records(days):
304
+ """同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
305
+ key = (str(paths.USAGE_DIR), int(days))
306
+ now = time.time()
307
+ with LOCK:
308
+ cached = _ROUTING_RECORDS_CACHE.get(key)
309
+ if cached and now - cached[0] <= _ROUTING_TTL:
310
+ return list(cached[1]), list(cached[2])
311
+ calls, quality = _iter_records(days), _iter_quality_records(days)
312
+ with LOCK:
313
+ _ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
314
+ return calls, quality
315
+
316
+
317
+ def record_quality_for_run(run_id, quality_ok, agent=""):
318
+ """把验收结论归因到最终实际产出者,不重复计入用量。"""
319
+ try:
320
+ run_id = str(run_id or "")[:64]
321
+ if not run_id:
322
+ return 0
323
+ calls = [r for r in _iter_records(2)
324
+ if r.get("run_id") == run_id and r.get("ok") is True]
325
+ prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
326
+ calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
327
+ agent = str(agent or "")[:40]
328
+ unique = {}
329
+ for row in calls:
330
+ key = tuple(str(row.get(k) or "") for k in
331
+ ("task_id", "task_type", "role", "agent", "model", "provider"))
332
+ unique[key] = row
333
+ if not unique:
334
+ return 0
335
+ existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
336
+ r.get("model"), r.get("provider"))
337
+ for r in _iter_quality_records(2)}
338
+ day = time.strftime("%Y-%m-%d")
339
+ rows = []
340
+ for row in unique.values():
341
+ identity = (run_id, row.get("role"), row.get("agent"),
342
+ row.get("model"), row.get("provider"))
343
+ if identity in existing:
344
+ continue
345
+ # 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
346
+ # 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
347
+ row_quality_ok = bool(quality_ok)
348
+ if agent and str(row.get("agent") or "") != agent:
349
+ row_quality_ok = False
350
+ rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
351
+ "run_id": run_id, "task_id": row.get("task_id") or "",
352
+ "task_type": row.get("task_type") or "unknown",
353
+ "role": row.get("role") or "unknown",
354
+ "agent": row.get("agent") or "unknown",
355
+ "model": row.get("model") or "(默认)",
356
+ "provider": row.get("provider") or "",
357
+ "quality_ok": row_quality_ok})
358
+ if not rows:
359
+ return 0
360
+ with LOCK:
361
+ paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
362
+ with open(_quality_file(day), "a", encoding="utf-8") as handle:
363
+ for row in rows:
364
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
365
+ _ROUTING_CACHE["data"].clear()
366
+ _ROUTING_RECORDS_CACHE.clear()
367
+ return len(rows)
368
+ except Exception:
369
+ return 0
370
+
371
+
372
+ def _num(r, key):
373
+ return _parse_int(r.get(key))
374
+
375
+
376
+ def _percentile(values, percentile):
377
+ """线性插值百分位,空样本返回 0。"""
378
+ if not values:
379
+ return 0.0
380
+ ordered = sorted(float(v) for v in values)
381
+ if len(ordered) == 1:
382
+ return round(ordered[0], 2)
383
+ pos = (len(ordered) - 1) * float(percentile)
384
+ lo = int(math.floor(pos))
385
+ hi = int(math.ceil(pos))
386
+ if lo == hi:
387
+ return round(ordered[lo], 2)
388
+ return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
389
+
390
+
391
+ def routing_stats(task_type="", role="", agent="", model="", provider="",
392
+ days=90, min_samples=3):
393
+ """返回路由用的近期真实运行指标。
394
+
395
+ 只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
396
+ 全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
397
+ """
398
+ try:
399
+ task_type = str(task_type or "")[:32]
400
+ role = str(role or "")[:40]
401
+ agent = str(agent or "")[:40]
402
+ model = str(model or "")[:80]
403
+ provider = str(provider or "")[:60]
404
+ days = max(0, int(days))
405
+ min_samples = max(1, int(min_samples))
406
+ except (TypeError, ValueError):
407
+ days, min_samples = 90, 3
408
+ # DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
409
+ # 复用另一套台账的线上指标。
410
+ key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
411
+ days, min_samples)
412
+ now = time.time()
413
+ with LOCK:
414
+ cached = _ROUTING_CACHE["data"].get(key)
415
+ if cached and now - cached[0] <= _ROUTING_TTL:
416
+ return dict(cached[1])
417
+ records, quality_records = _routing_records(days)
418
+
419
+ def matches(record, filters):
420
+ return all(str(record.get(field) or "") == value
421
+ for field, value in filters.items() if value)
422
+
423
+ # 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
424
+ exact = {"task_type": task_type, "role": role, "agent": agent,
425
+ "model": model, "provider": provider}
426
+ fallbacks = [(exact, "exact")]
427
+ task_role = {"task_type": task_type, "role": role}
428
+ if task_role != exact:
429
+ fallbacks.append((task_role, "task-role"))
430
+ if task_type:
431
+ fallbacks.append(({"task_type": task_type}, "task"))
432
+ fallbacks.append(({}, "global"))
433
+ selected, label = [], "global"
434
+ for filters, candidate_label in fallbacks:
435
+ candidate = [r for r in records if matches(r, filters)]
436
+ if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
437
+ selected, label = candidate, candidate_label
438
+ break
439
+ chosen_filters = next((filters for filters, candidate_label in fallbacks
440
+ if candidate_label == label), {})
441
+ quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
442
+ success_rows = quality_selected or selected
443
+ success_key = "quality_ok" if quality_selected else "ok"
444
+ successes = sum(1 for r in success_rows if bool(r.get(success_key)))
445
+ durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
446
+ costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
447
+ samples = len(selected)
448
+ success_samples = len(success_rows)
449
+ result = {
450
+ "samples": samples,
451
+ "quality_samples": len(quality_selected),
452
+ "success_samples": success_samples,
453
+ "successes": successes,
454
+ "success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
455
+ "p50_duration_s": _percentile(durations, 0.50),
456
+ "p95_duration_s": _percentile(durations, 0.95),
457
+ "avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
458
+ "fallback": label,
459
+ }
460
+ with LOCK:
461
+ _ROUTING_CACHE["data"][key] = (now, dict(result))
462
+ return result
463
+
464
+
465
+ def _group(records, key):
264
466
  """按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s}}。"""
265
467
  groups = {}
266
468
  for r in records:
package/app/main.py CHANGED
@@ -284,6 +284,18 @@ class Handler(BaseHTTPRequestHandler):
284
284
  except ValueError:
285
285
  edays = 90
286
286
  return self._json(200, usage.estimate(task_type=ttype, days=edays))
287
+ if path == "/api/dispatch/replay":
288
+ from core import dispatch_log
289
+ q = parse_qs(urlparse(self.path).query)
290
+ try:
291
+ limit = max(1, min(1000, int((q.get("limit") or ["100"])[0])))
292
+ except (TypeError, ValueError):
293
+ limit = 100
294
+ events = dispatch_log.replay(
295
+ run_id=(q.get("run_id") or [""])[0],
296
+ task_type=(q.get("task_type") or [""])[0],
297
+ limit=limit)
298
+ return self._json(200, {"events": events, "count": len(events)})
287
299
  if path == "/api/diagnostics/bundle":
288
300
  # 诊断包(zip):脱敏错误台账 + 用量台账 + 环境元信息,供用户贴 Issue
289
301
  from core import telemetry
@@ -296,6 +308,18 @@ class Handler(BaseHTTPRequestHandler):
296
308
  # 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
297
309
  from core import telemetry
298
310
  return self._json(200, telemetry.issue_report(days=30))
311
+ if path == "/api/ports":
312
+ # 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
313
+ # 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
314
+ from core import portscan
315
+ q = parse_qs(urlparse(self.path).query)
316
+ ports = portscan.listening_ports()
317
+ focus = (q.get("port") or [""])[0]
318
+ if focus.isdigit():
319
+ ports = [p for p in ports if p["port"] == int(focus)]
320
+ for p in ports:
321
+ p["self"] = p.get("pid") == os.getpid()
322
+ return self._json(200, {"ports": ports})
299
323
  m = re.match(r"^/api/runs/([^/]+)$", path)
300
324
  if m:
301
325
  run = store.get_run(m.group(1))
@@ -560,6 +584,22 @@ class Handler(BaseHTTPRequestHandler):
560
584
  return self._json(200, {"ok": True, "health": health.snapshot()})
561
585
  if path == "/api/attachments":
562
586
  return self._api_add_attachment()
587
+ if path == "/api/ports/close":
588
+ # 温和关闭端口占用进程(借鉴 leftopen):SIGTERM only、关前重验
589
+ # PID 绑定;系统进程/自身服务在 portscan 内拒关。设备控制权守卫
590
+ # 已在上方统一生效(不在豁免清单里)。
591
+ from core import portscan
592
+ body = self._body()
593
+ try:
594
+ cport = int(body.get("port") or 0)
595
+ except (TypeError, ValueError):
596
+ return self._json(400, {"error": "port 必须是数字"})
597
+ if not (1 <= cport <= 65535):
598
+ return self._json(400, {"error": "port 越界"})
599
+ ok, msg = portscan.close_port(cport)
600
+ return self._json(200 if ok else 409,
601
+ {"ok": ok, "message": msg,
602
+ "error": None if ok else msg})
563
603
  if path == "/api/dir/save":
564
604
  # 「查看文件」弹窗编辑保存(本机 + 控制权 + 防穿越 + mtime 冲突检测)
565
605
  return self._api_dir_save()
@@ -713,14 +753,13 @@ class Handler(BaseHTTPRequestHandler):
713
753
  if path == "/api/selfupdate/apply":
714
754
  from core import selfupdate
715
755
  try:
716
- res = selfupdate.apply_upgrade()
756
+ res = selfupdate.apply_upgrade(PORT)
717
757
  except Exception:
718
758
  log.exception("自更新任务创建失败")
719
759
  return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
720
760
  return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
721
761
  if path == "/api/selfupdate/restart":
722
762
  from core import selfupdate
723
- global PORT
724
763
  if not selfupdate.relaunch(PORT):
725
764
  return self._json(400, {"error": "重启参数非法"})
726
765
  def _bye():
@@ -2407,13 +2446,47 @@ def main():
2407
2446
  # 分发,表现为"时好时坏");加独占锁后双起在这里干净失败并指路。
2408
2447
  # (此处不能局部 import os:会让 os 变 main() 的局部名,后面 2127 行
2409
2448
  # 的 os.name 直接 UnboundLocalError——顶部已有全局导入,直接用)
2410
- hint = ""
2411
- if os.name == "nt":
2412
- hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
2413
- "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
2414
- % args.port)
2415
- raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
2416
- % (args.port, e, hint))
2449
+ # 端口占用自动清场(用户拍板 2026-09-21):升级/重启最常见的占用者是
2450
+ # 没退干净的 CodeBee 自家旧实例——先杀再起,别让用户手动 netstat+taskkill。
2451
+ # 判定(main.py 完整路径/npm 打包路径)与清场在 core.portguard。
2452
+ from core import portscan as _ps, portguard as _pg
2453
+ _hint = ""
2454
+ try:
2455
+ _cleared, _hint = _pg.clear_stale_port(
2456
+ args.port, paths.APP_DIR / "main.py")
2457
+ except Exception:
2458
+ _cleared, _hint = False, ""
2459
+ if _cleared:
2460
+ try:
2461
+ print("[CodeBee] 端口 %d 被旧实例占用,已自动清场,正在重新绑定…"
2462
+ % args.port, flush=True)
2463
+ time.sleep(1.0) # 让被清进程的监听 socket 完全释放
2464
+ httpd = ThreadedServer((args.host, args.port), Handler)
2465
+ except OSError:
2466
+ httpd = None
2467
+ else:
2468
+ httpd = None
2469
+ if httpd is None:
2470
+ # 清场没成:带占用者指认退出(识别不出回落手工排查提示)
2471
+ hint = ""
2472
+ if os.name == "nt":
2473
+ hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
2474
+ "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
2475
+ % args.port)
2476
+ try:
2477
+ for _h in _ps.listening_ports():
2478
+ if _h.get("port") != args.port:
2479
+ continue
2480
+ _who = _h.get("process") or "未知进程"
2481
+ _proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
2482
+ hint = ("占用者:PID %d(%s%s)%s"
2483
+ % (_h.get("pid") or 0, _who, _proj,
2484
+ (";" + _hint) if _hint else ";旧进程杀掉或换 --port 重启"))
2485
+ break
2486
+ except Exception:
2487
+ pass
2488
+ raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
2489
+ % (args.port, e, hint))
2417
2490
 
2418
2491
  def _announce_public(url):
2419
2492
  print("[CodeBee] 公网 %s/?token=%s" % (url, tok))
package/app/pet.py CHANGED
@@ -1283,7 +1283,8 @@ class PetApp:
1283
1283
  command=lambda: self._set_lang("en"),
1284
1284
  variable=self._lang_var, value="en")
1285
1285
  m.add_separator()
1286
- m.add_command(label=self._L("close"), command=self._bye)
1286
+ m.add_command(label=self._L("close"),
1287
+ command=lambda: self._bye(write_setting=True))
1287
1288
  return m
1288
1289
 
1289
1290
  def _set_mode(self, mode):
@@ -1308,6 +1309,8 @@ class PetApp:
1308
1309
  cv = self.cv
1309
1310
  self._press = None
1310
1311
  self._moved = False
1312
+ self._drag_to = None
1313
+ self._drag_pending = False
1311
1314
  cv.bind("<ButtonPress-1>", self._on_press)
1312
1315
  cv.bind("<B1-Motion>", self._on_motion)
1313
1316
  cv.bind("<ButtonRelease-1>", self._on_release)
@@ -1316,22 +1319,32 @@ class PetApp:
1316
1319
  self.root.protocol("WM_DELETE_WINDOW", self._bye)
1317
1320
 
1318
1321
  def _on_press(self, e):
1319
- self._press = e
1322
+ # 窗口拖动起点:屏幕坐标 + 窗口原点一起记(scan_mark/scan_dragto 是
1323
+ # canvas 系 widget 的子命令,顶层窗口没有——此前绑定在这里的拖动
1324
+ # 实际全部抛 AttributeError 被 except 吞掉,表现为「拖不动」)
1325
+ self._press = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y())
1320
1326
  self._moved = False
1321
- try:
1322
- self.root.scan_mark(e.x_root, e.y_root) # Tk 内置 C 级拖动,跟手
1323
- except Exception:
1324
- pass
1325
1327
 
1326
1328
  def _on_motion(self, e):
1327
1329
  if not self._press:
1328
1330
  return
1329
- if (abs(e.x - self._press.x) + abs(e.y - self._press.y) > 4):
1331
+ px, py, wx, wy = self._press
1332
+ if (abs(e.x_root - px) + abs(e.y_root - py) > 4):
1330
1333
  self._moved = True
1331
- # 逐事件 geometry() 会把透明层窗拖成PPT(每次回调都走 Python+重排);
1332
- # scan_dragto C 里完成同样的移动,丝滑
1334
+ # geometry 逐事件会卡成 PPT;这里合并到 ~60fps 节流(事件风暴只在
1335
+ # 节流窗内记终点,一次 geometry 落位),实测跟手且不吃 CPU
1336
+ self._drag_to = (wx + (e.x_root - px), wy + (e.y_root - py))
1337
+ if self._drag_pending:
1338
+ return
1339
+ self._drag_pending = True
1340
+ self.root.after(16, self._apply_drag)
1341
+
1342
+ def _apply_drag(self):
1343
+ self._drag_pending = False
1344
+ if not self._drag_to:
1345
+ return
1333
1346
  try:
1334
- self.root.scan_dragto(e.x_root, e.y_root, 1)
1347
+ self.root.geometry("+%d+%d" % self._drag_to)
1335
1348
  except Exception:
1336
1349
  pass
1337
1350
 
@@ -1344,7 +1357,17 @@ class PetApp:
1344
1357
  else:
1345
1358
  self._open_ui()
1346
1359
 
1347
- def _bye(self):
1360
+ def _bye(self, write_setting=False):
1361
+ """退出。write_setting=True(右键菜单「关闭桌宠」)先把 pet_enabled=False
1362
+ 写回服务端设置——否则看护/下次启动都会按设置里的 enabled 把蜜蜂复活,
1363
+ 用户点关闭等于没关(2026-09-21 用户实测「宠物关不了」的根因:菜单
1364
+ 关闭只销毁窗口不落设置,与注释宣称的契约相反)。服务端 pet_enabled
1365
+ 已为 False 的自离路径(轮询发现/miss 超限)无需再写。"""
1366
+ if write_setting:
1367
+ try:
1368
+ self._post_settings({"pet_enabled": False})
1369
+ except Exception:
1370
+ pass
1348
1371
  try:
1349
1372
  self._save_cfg(x=self.root.winfo_x(), y=self.root.winfo_y())
1350
1373
  except Exception: