coze_lab 0.1.27 → 0.1.30

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/index.js CHANGED
@@ -228,15 +228,21 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
228
228
  req = urllib.request.Request(
229
229
  f"{_COZE_API}/api/permission/oauth2/token",
230
230
  data=payload,
231
- headers={"Content-Type": "application/json"},
231
+ headers={
232
+ "Content-Type": "application/json",
233
+ "x-tt-env": "ppe_cozelab",
234
+ "x-use-ppe": "1",
235
+ },
232
236
  )
233
237
  with urllib.request.urlopen(req, timeout=10) as resp:
234
238
  data = json.loads(resp.read())
235
239
  if data.get("access_token"):
240
+ existing = _load_credentials() or {}
236
241
  creds = {
237
242
  "access_token": data["access_token"],
238
243
  "refresh_token": data.get("refresh_token", refresh_token),
239
244
  "expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
245
+ "workspace_id": existing.get("workspace_id", ""),
240
246
  }
241
247
  _save_credentials(creds)
242
248
  debug_log("Token refreshed successfully.")
@@ -245,22 +251,34 @@ def _refresh_token(refresh_token: str) -> Optional[str]:
245
251
  debug_log(f"Token refresh failed: {e}")
246
252
  return None
247
253
 
254
+ def _token_from_credentials() -> Optional[str]:
255
+ creds = _load_credentials()
256
+ if not creds:
257
+ return None
258
+ expires_at_sec = creds.get("expires_at", 0) / 1000
259
+ remaining = expires_at_sec - time.time()
260
+ if remaining > _REFRESH_THRESHOLD:
261
+ debug_log(f"Cached token valid, expires in {int(remaining)}s.")
262
+ return creds.get("access_token")
263
+ if creds.get("refresh_token"):
264
+ debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
265
+ new_token = _refresh_token(creds["refresh_token"])
266
+ if new_token:
267
+ return new_token
268
+ debug_log("Refresh failed.")
269
+ return None
270
+
248
271
  def get_fresh_token() -> Optional[str]:
249
- """Return a valid access token, refreshing if needed. Falls back to env var."""
272
+ """Return a valid access token, refreshing if needed."""
273
+ env_token = os.environ.get("COZELOOP_API_TOKEN")
274
+ env_coze_token = os.environ.get("COZE_API_TOKEN")
275
+ is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
276
+ if is_cloud:
277
+ return env_token or env_coze_token or _token_from_credentials()
250
278
  creds = _load_credentials()
251
279
  if creds:
252
- expires_at_sec = creds.get("expires_at", 0) / 1000
253
- remaining = expires_at_sec - time.time()
254
- if remaining > _REFRESH_THRESHOLD:
255
- debug_log(f"Cached token valid, expires in {int(remaining)}s.")
256
- return creds["access_token"]
257
- if creds.get("refresh_token"):
258
- debug_log(f"Token expiring in {int(remaining)}s, refreshing...")
259
- new_token = _refresh_token(creds["refresh_token"])
260
- if new_token:
261
- return new_token
262
- debug_log("Refresh failed, falling back to env var.")
263
- return os.environ.get("COZELOOP_API_TOKEN")
280
+ return _token_from_credentials()
281
+ return env_token or env_coze_token
264
282
 
265
283
  # -------------------------------------------------------------------------
266
284
 
@@ -1346,6 +1364,125 @@ from typing import Optional, List, Dict, Any
1346
1364
  _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
1347
1365
  _COZE_API = "https://api.coze.cn"
1348
1366
  _REFRESH_THRESHOLD = 10 * 60
1367
+ _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
1368
+ _OTEL_SUFFIX = "/v1/loop/opentelemetry"
1369
+
1370
+
1371
+ # --- coze-context parsing -------------------------------------------------
1372
+ # User messages may embed a block like:
1373
+ # <coze-context>
1374
+ # account_id: 0
1375
+ # agent_id: 7644920552473395499
1376
+ # session_id: 7644919579054997796
1377
+ # message_id: 04dd5246-...
1378
+ # </coze-context>
1379
+ # We parse its key:value pairs and inject them into the trace.
1380
+
1381
+ _COZE_CTX_OPEN = "<coze-context>"
1382
+ _COZE_CTX_CLOSE = "</coze-context>"
1383
+
1384
+
1385
+ def parse_coze_context(text: str) -> Dict[str, str]:
1386
+ """Extract the LAST <coze-context> block's key:value pairs from text.
1387
+
1388
+ Returns {} if no block is present. Tag keys are prefixed with
1389
+ 'coze_' by the caller; here we return raw keys as written.
1390
+ """
1391
+ if not text or _COZE_CTX_OPEN not in text:
1392
+ return {}
1393
+ # Take the last occurrence (latest context wins).
1394
+ open_idx = text.rfind(_COZE_CTX_OPEN)
1395
+ close_idx = text.find(_COZE_CTX_CLOSE, open_idx)
1396
+ if close_idx == -1:
1397
+ return {}
1398
+ body = text[open_idx + len(_COZE_CTX_OPEN):close_idx]
1399
+ # The block may arrive with real newlines, OR with literal backslash-n
1400
+ # (e.g. when the whole message is an embedded JSON string that was never
1401
+ # un-escaped). Normalize both forms before splitting into lines.
1402
+ body = body.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
1403
+ result: Dict[str, str] = {}
1404
+ for line in body.splitlines():
1405
+ line = line.strip()
1406
+ if not line or ":" not in line:
1407
+ continue
1408
+ key, _, value = line.partition(":")
1409
+ key = key.strip()
1410
+ value = value.strip()
1411
+ if key:
1412
+ result[key] = value
1413
+ return result
1414
+
1415
+
1416
+ def coze_context_tags(text: str) -> Dict[str, str]:
1417
+ """Return coze-context kv as trace tags, prefixed with 'coze_'."""
1418
+ return {f"coze_{k}": v for k, v in parse_coze_context(text).items()}
1419
+
1420
+
1421
+ def turn_coze_context(turn: Dict[str, Any]) -> Dict[str, str]:
1422
+ """Extract coze-context from a grouped turn with fallbacks for Codex rollout shapes."""
1423
+ texts = [turn.get("user_message_text", "")]
1424
+ for msg in turn.get("input_messages", []):
1425
+ if isinstance(msg, dict):
1426
+ texts.append(str(msg.get("content", "")))
1427
+ user_payload = turn.get("user_message")
1428
+ if isinstance(user_payload, dict):
1429
+ texts.append(extract_message_content_text(user_payload))
1430
+ for text in texts:
1431
+ ctx = parse_coze_context(text)
1432
+ if ctx:
1433
+ return ctx
1434
+ return {}
1435
+
1436
+
1437
+ # --- trace upload failure / logid capture ---------------------------------
1438
+ def _extract_logid(msg: str) -> str:
1439
+ """Pull the server logid out of an SDK error message, if present.
1440
+
1441
+ SDK failure messages embed it as 'logid=XXXX' (sometimes within brackets).
1442
+ """
1443
+ if not msg:
1444
+ return ""
1445
+ marker = "logid="
1446
+ idx = msg.find(marker)
1447
+ if idx == -1:
1448
+ return ""
1449
+ rest = msg[idx + len(marker):]
1450
+ logid = []
1451
+ for ch in rest:
1452
+ if ch.isalnum():
1453
+ logid.append(ch)
1454
+ else:
1455
+ break
1456
+ return "".join(logid)
1457
+
1458
+
1459
+ def _make_finish_event_processor(upload_events: Optional[List[str]] = None):
1460
+ """Return a trace_finish_event_processor that surfaces failures + logid.
1461
+
1462
+ The CozeLoop SDK calls this for each flush event; on failure we print the
1463
+ server logid to stderr so it can be handed to platform support for tracing
1464
+ the root cause (e.g. via \`bytedcli log get-logid-log <logid>\`).
1465
+ """
1466
+ def _processor(info):
1467
+ try:
1468
+ if not getattr(info, "is_event_fail", False):
1469
+ hook_log("upload success")
1470
+ return
1471
+ detail = getattr(info, "detail_msg", "") or ""
1472
+ if upload_events is not None:
1473
+ upload_events.append(detail or "trace export failed")
1474
+ logid = _extract_logid(detail)
1475
+ if logid:
1476
+ hook_log(f"upload failed logid={logid} detail={detail[:500]}")
1477
+ print(f"[CozeLoop] 上报失败 logid={logid} (可用 bytedcli log get-logid-log {logid} 排查)", file=sys.stderr)
1478
+ else:
1479
+ hook_log(f"upload failed detail={detail[:500]}")
1480
+ print(f"[CozeLoop] 上报失败: {detail[:300]}", file=sys.stderr)
1481
+ except Exception:
1482
+ pass
1483
+ return _processor
1484
+
1485
+
1349
1486
 
1350
1487
  def _get_credentials_path() -> Path:
1351
1488
  return Path.home() / ".cozeloop" / "credentials.json"
@@ -1375,15 +1512,21 @@ def _refresh_token(refresh_tok: str):
1375
1512
  req = urllib.request.Request(
1376
1513
  f"{_COZE_API}/api/permission/oauth2/token",
1377
1514
  data=payload,
1378
- headers={"Content-Type": "application/json"},
1515
+ headers={
1516
+ "Content-Type": "application/json",
1517
+ "x-tt-env": "ppe_cozelab",
1518
+ "x-use-ppe": "1",
1519
+ },
1379
1520
  )
1380
1521
  with urllib.request.urlopen(req, timeout=10) as resp:
1381
1522
  data = json.loads(resp.read())
1382
1523
  if data.get("access_token"):
1524
+ existing = _load_credentials() or {}
1383
1525
  creds = {
1384
1526
  "access_token": data["access_token"],
1385
1527
  "refresh_token": data.get("refresh_token", refresh_tok),
1386
- "expires_at": data.get("expires_in", 0) * 1000 # unix timestamp in seconds
1528
+ "expires_at": data.get("expires_in", 0) * 1000, # unix timestamp in seconds
1529
+ "workspace_id": existing.get("workspace_id", ""),
1387
1530
  }
1388
1531
  _save_credentials(creds)
1389
1532
  return creds["access_token"]
@@ -1391,37 +1534,168 @@ def _refresh_token(refresh_tok: str):
1391
1534
  pass
1392
1535
  return None
1393
1536
 
1537
+
1538
+ def _normalize_api_base_url(url: str) -> str:
1539
+ base = (url or "").strip().rstrip("/")
1540
+ if base.endswith(_OTEL_SUFFIX + "/v1/traces"):
1541
+ return base[:-len(_OTEL_SUFFIX + "/v1/traces")].rstrip("/")
1542
+ if base.endswith("/api/v1/loop/opentelemetry/v1/traces"):
1543
+ return base[:-len("/v1/loop/opentelemetry/v1/traces")].rstrip("/")
1544
+ if base.endswith(_OTEL_SUFFIX):
1545
+ return base[:-len(_OTEL_SUFFIX)].rstrip("/")
1546
+ if base.endswith("/api/v1/loop/opentelemetry"):
1547
+ return base[:-len("/v1/loop/opentelemetry")].rstrip("/")
1548
+ if base.endswith("/api/v1"):
1549
+ return base[:-len("/v1")].rstrip("/")
1550
+ return base
1551
+
1552
+
1553
+ def get_api_base_url() -> str:
1554
+ return _normalize_api_base_url(
1555
+ os.environ.get("COZELOOP_API_BASE_URL", "")
1556
+ )
1557
+
1558
+
1559
+ def _token_from_credentials():
1560
+ creds = _load_credentials()
1561
+ if not creds:
1562
+ return None
1563
+ remaining = creds.get("expires_at", 0) / 1000 - time.time()
1564
+ if remaining > _REFRESH_THRESHOLD:
1565
+ return creds.get("access_token")
1566
+ if creds.get("refresh_token"):
1567
+ new_token = _refresh_token(creds["refresh_token"])
1568
+ if new_token:
1569
+ return new_token
1570
+ return None
1571
+
1572
+
1394
1573
  def get_fresh_token():
1574
+ env_token = os.environ.get("COZELOOP_API_TOKEN")
1575
+ env_coze_token = os.environ.get("COZE_API_TOKEN")
1576
+ is_cloud = os.environ.get("COZELAB_ONBOARD_CLOUD", "").lower() in ("1", "true", "yes")
1577
+ if is_cloud:
1578
+ return env_token or env_coze_token or _token_from_credentials()
1579
+
1580
+ # Local onboard used to write a short-lived access token into cozeloop.env.
1581
+ # If credentials exist but cannot be refreshed, do not fall back to that stale
1582
+ # env token; failing closed keeps state from advancing on a known-bad token.
1395
1583
  creds = _load_credentials()
1396
1584
  if creds:
1397
- remaining = creds.get("expires_at", 0) / 1000 - time.time()
1398
- if remaining > _REFRESH_THRESHOLD:
1399
- return creds["access_token"]
1400
- if creds.get("refresh_token"):
1401
- new_token = _refresh_token(creds["refresh_token"])
1402
- if new_token:
1403
- return new_token
1404
- return os.environ.get("COZELOOP_API_TOKEN")
1585
+ return _token_from_credentials()
1586
+ if env_token and not env_coze_token:
1587
+ token = _token_from_credentials()
1588
+ if token:
1589
+ return token
1590
+ return env_token
1591
+ if env_token:
1592
+ return env_token
1593
+ if env_coze_token:
1594
+ return env_coze_token
1595
+ return _token_from_credentials()
1405
1596
  # -------------------------------------------------------------------------
1406
1597
 
1407
1598
  # --- SDK Import ---
1408
- try:
1599
+ # 最低版本:set_finish_time 是 cozeloop SDK 0.1.25 才引入的方法,云端预装旧版(<=0.1.24)
1600
+ # 调用会抛 AttributeError 并使整条 trace 上报失败。统一用能力探测(hasattr)判定,与
1601
+ # _set_finish_time_safe 兜底口径一致,避免版本号字符串比较的边界问题。
1602
+ _MIN_COZELOOP_SPEC = "cozeloop>=0.1.28"
1603
+
1604
+
1605
+ def _cozeloop_capable():
1606
+ """已装 cozeloop 是否具备 set_finish_time 能力。未装/异常返回 None(无法判定)。"""
1607
+ try:
1608
+ import cozeloop # noqa: F401
1609
+ except ImportError:
1610
+ return None
1611
+ try:
1612
+ return hasattr(cozeloop.Span, "set_finish_time")
1613
+ except Exception:
1614
+ return False
1615
+
1616
+
1617
+ def _ensure_cozeloop_sdk():
1618
+ """确保 cozeloop 可 import 且尽量满足 set_finish_time 能力。
1619
+
1620
+ 返回 True 表示 cozeloop 可 import(不保证版本达标——能力不足时由
1621
+ _set_finish_time_safe 兜底,不阻断上报);返回 False 表示完全无法 import。
1622
+ """
1623
+ capable = _cozeloop_capable()
1624
+ if capable is True:
1625
+ return True
1626
+ # 已装但能力不足(capable is False)→ 需升级;未装(None)→ 需安装。
1627
+ import subprocess
1628
+ import importlib
1629
+ import site
1630
+ # 能力不足时强制升级到带下限的版本;未装时直接装下限版本。
1631
+ pkg = _MIN_COZELOOP_SPEC
1632
+ base_flags = ["--quiet", "--disable-pip-version-check", "--upgrade"]
1633
+ attempts = (
1634
+ [*base_flags, pkg],
1635
+ [*base_flags, "--break-system-packages", pkg],
1636
+ [*base_flags, "--break-system-packages", "--user", pkg],
1637
+ )
1638
+ for extra in attempts:
1639
+ try:
1640
+ subprocess.run(
1641
+ [sys.executable, "-m", "pip", "install", *extra],
1642
+ timeout=180, check=True,
1643
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
1644
+ )
1645
+ except Exception:
1646
+ continue
1647
+ try:
1648
+ importlib.reload(site)
1649
+ user_site = site.getusersitepackages()
1650
+ for p in ([user_site] if isinstance(user_site, str) else list(user_site)):
1651
+ if p and p not in sys.path:
1652
+ sys.path.insert(0, p)
1653
+ importlib.invalidate_caches()
1654
+ import cozeloop # noqa: F401
1655
+ print("[CozeLoop] cozeloop SDK installed/upgraded at runtime.", file=sys.stderr)
1656
+ return True
1657
+ except ImportError:
1658
+ continue
1659
+ # 升级没成功,但只要原本能 import(capable is False)就继续——兜底会处理能力缺失。
1660
+ return capable is False
1661
+
1662
+
1663
+ if _ensure_cozeloop_sdk():
1409
1664
  import cozeloop
1410
1665
  from cozeloop.spec.tracespec import (
1411
1666
  Runtime, ModelInput, ModelMessage, ModelToolChoice,
1412
1667
  ModelOutput, ModelChoice, ModelToolCall, ModelToolCallFunction,
1413
1668
  ModelMessagePart, ModelMessagePartType
1414
1669
  )
1415
- except ImportError:
1416
- print("Error: cozeloop SDK not found. Please install it with: pip install cozeloop", file=sys.stderr)
1670
+ else:
1671
+ print("Error: cozeloop SDK not found and auto-install failed. Try: pip install cozeloop", file=sys.stderr)
1417
1672
  sys.exit(1)
1418
1673
 
1419
1674
  # --- Configuration ---
1420
1675
  DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
1421
1676
 
1422
1677
 
1678
+ def _log_file_path() -> str:
1679
+ return os.environ.get("COZELOOP_HOOK_LOG", "").strip()
1680
+
1681
+
1682
+ def hook_log(message: str):
1683
+ """Append one diagnostic line to the hook log, if configured."""
1684
+ log_path = _log_file_path()
1685
+ if not log_path:
1686
+ return
1687
+ try:
1688
+ p = Path(log_path).expanduser()
1689
+ p.parent.mkdir(parents=True, exist_ok=True)
1690
+ with p.open("a", encoding="utf-8") as f:
1691
+ f.write(f"{datetime.now().isoformat()} {message}\n")
1692
+ except Exception:
1693
+ pass
1694
+
1695
+
1423
1696
  def debug_log(message: str):
1424
1697
  """Print debug message if debug mode is enabled."""
1698
+ hook_log(f"DEBUG {message}")
1425
1699
  if DEBUG:
1426
1700
  print(f"[COZELOOP_HOOK_DEBUG] {datetime.now().isoformat()} - {message}", file=sys.stderr)
1427
1701
 
@@ -1509,6 +1783,101 @@ def read_rollout_messages(transcript_path: str, start_line: int = 0) -> List[Dic
1509
1783
  return entries
1510
1784
 
1511
1785
 
1786
+ def _add_unique_path(paths: List[Path], p: Optional[Path]):
1787
+ if not p:
1788
+ return
1789
+ try:
1790
+ resolved = p.expanduser().resolve()
1791
+ except Exception:
1792
+ return
1793
+ if resolved not in paths:
1794
+ paths.append(resolved)
1795
+
1796
+
1797
+ def _candidate_codex_homes() -> List[Path]:
1798
+ homes: List[Path] = []
1799
+ _add_unique_path(homes, Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else None)
1800
+
1801
+ log_path = _log_file_path()
1802
+ if log_path:
1803
+ try:
1804
+ log_parent = Path(log_path).expanduser().resolve().parent
1805
+ if log_parent.name == "hooks":
1806
+ _add_unique_path(homes, log_parent.parent)
1807
+ except Exception:
1808
+ pass
1809
+
1810
+ _add_unique_path(homes, Path.home() / ".codex")
1811
+
1812
+ agents_root = Path.home() / ".coze" / "agents"
1813
+ try:
1814
+ if agents_root.exists():
1815
+ for child in agents_root.iterdir():
1816
+ _add_unique_path(homes, child / "codex-home")
1817
+ except Exception:
1818
+ pass
1819
+
1820
+ return homes
1821
+
1822
+
1823
+ def _latest_file(paths: List[Path]) -> Optional[Path]:
1824
+ existing = []
1825
+ for p in paths:
1826
+ try:
1827
+ if p.is_file():
1828
+ existing.append(p)
1829
+ except Exception:
1830
+ pass
1831
+ if not existing:
1832
+ return None
1833
+ return max(existing, key=lambda p: p.stat().st_mtime)
1834
+
1835
+
1836
+ def find_latest_transcript() -> Optional[str]:
1837
+ """Best-effort fallback when Codex does not pass hook stdin."""
1838
+ candidates: List[Path] = []
1839
+ for codex_home in _candidate_codex_homes():
1840
+ sessions_dir = codex_home / "sessions"
1841
+ if not sessions_dir.exists():
1842
+ continue
1843
+ try:
1844
+ candidates.extend(sessions_dir.rglob("rollout-*.jsonl"))
1845
+ except Exception as e:
1846
+ hook_log(f"fallback scan failed dir={sessions_dir} error={repr(e)}")
1847
+
1848
+ latest = _latest_file(candidates)
1849
+ if latest:
1850
+ return str(latest)
1851
+
1852
+ for codex_home in _candidate_codex_homes():
1853
+ sessions_dir = codex_home / "sessions"
1854
+ if not sessions_dir.exists():
1855
+ continue
1856
+ try:
1857
+ candidates.extend(sessions_dir.rglob("*.jsonl"))
1858
+ except Exception:
1859
+ pass
1860
+
1861
+ latest = _latest_file(candidates)
1862
+ return str(latest) if latest else None
1863
+
1864
+
1865
+ def recover_hook_input(reason: str) -> Optional[Dict[str, Any]]:
1866
+ transcript_path = find_latest_transcript()
1867
+ if not transcript_path:
1868
+ hook_log(f"fallback failed reason={reason} no transcript found")
1869
+ print(f"[CozeLoop] Hook input missing ({reason}); no Codex transcript found.", file=sys.stderr)
1870
+ return None
1871
+ hook_log(f"fallback transcript reason={reason} path={transcript_path}")
1872
+ print(f"[CozeLoop] Hook input missing ({reason}); fallback transcript: {transcript_path}", file=sys.stderr)
1873
+ return {
1874
+ "hook_event_name": "Stop",
1875
+ "session_id": "",
1876
+ "transcript_path": transcript_path,
1877
+ "input_fallback": reason,
1878
+ }
1879
+
1880
+
1512
1881
  def parse_session_meta(entries: List[Dict[str, Any]]) -> Dict[str, Any]:
1513
1882
  """Extract session identity from session_meta entry."""
1514
1883
  result = {
@@ -1564,6 +1933,8 @@ def is_real_user_message(payload: Dict[str, Any]) -> bool:
1564
1933
  if item.get("type") != "input_text":
1565
1934
  continue
1566
1935
  text = item.get("text", "")
1936
+ if parse_coze_context(text):
1937
+ return True
1567
1938
  if text.startswith("<environment_context>"):
1568
1939
  continue
1569
1940
  if text.startswith("<permissions instructions>"):
@@ -1582,11 +1953,12 @@ def extract_user_text(payload: Dict[str, Any]) -> str:
1582
1953
  for item in payload.get("content", []):
1583
1954
  if isinstance(item, dict) and item.get("type") == "input_text":
1584
1955
  text = item.get("text", "")
1585
- if (not text.startswith("<environment_context>") and
1956
+ if (parse_coze_context(text) or
1957
+ (not text.startswith("<environment_context>") and
1586
1958
  not text.startswith("<permissions instructions>") and
1587
- not text.startswith("<turn_aborted>")):
1959
+ not text.startswith("<turn_aborted>"))):
1588
1960
  parts.append(text)
1589
- return "\\n".join(parts)
1961
+ return "\n".join(parts)
1590
1962
 
1591
1963
 
1592
1964
  def extract_assistant_text(payload: Dict[str, Any]) -> str:
@@ -1595,7 +1967,7 @@ def extract_assistant_text(payload: Dict[str, Any]) -> str:
1595
1967
  for item in payload.get("content", []):
1596
1968
  if isinstance(item, dict) and item.get("type") in ("output_text", "text"):
1597
1969
  parts.append(item.get("text", ""))
1598
- return "\\n".join(parts)
1970
+ return "\n".join(parts)
1599
1971
 
1600
1972
 
1601
1973
  def extract_message_content_text(payload: Dict[str, Any]) -> str:
@@ -1607,7 +1979,7 @@ def extract_message_content_text(payload: Dict[str, Any]) -> str:
1607
1979
  text = item.get("text", "")
1608
1980
  if text:
1609
1981
  parts.append(text)
1610
- return "\\n".join(parts)
1982
+ return "\n".join(parts)
1611
1983
 
1612
1984
 
1613
1985
  def truncate_text(text: str, limit: int = 12000) -> str:
@@ -1619,6 +1991,93 @@ def truncate_text(text: str, limit: int = 12000) -> str:
1619
1991
 
1620
1992
  # --- Message Grouping ---
1621
1993
 
1994
+ def _parse_ts(obj):
1995
+ """从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
1996
+
1997
+ Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
1998
+ start_time,避免回放时所有 span 挤在几毫秒内。
1999
+ """
2000
+ if not isinstance(obj, dict):
2001
+ return None
2002
+ ts = obj.get("timestamp") or obj.get("_ts")
2003
+ if not ts or not isinstance(ts, str):
2004
+ return None
2005
+ try:
2006
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
2007
+ except Exception:
2008
+ return None
2009
+
2010
+
2011
+ def _ts_ms(dt):
2012
+ """datetime → 毫秒时间戳(int);None → None。"""
2013
+ return int(dt.timestamp() * 1000) if dt is not None else None
2014
+
2015
+
2016
+ def _set_finish_time_safe(span, dt):
2017
+ """安全设置 span 结束时间。
2018
+
2019
+ set_finish_time 是 cozeloop SDK 0.1.25+ 的方法,云端旧版没有;缺失或异常都不能
2020
+ 让整条 trace 上报失败(real_*_ms tag 仍保留耗时信息)。
2021
+ """
2022
+ if dt is None:
2023
+ return
2024
+ fn = getattr(span, "set_finish_time", None)
2025
+ if fn is None:
2026
+ return
2027
+ try:
2028
+ fn(dt)
2029
+ except Exception:
2030
+ pass
2031
+
2032
+
2033
+ def _max_dt(*values):
2034
+ result = None
2035
+ for value in values:
2036
+ if value is not None and (result is None or value > result):
2037
+ result = value
2038
+ return result
2039
+
2040
+
2041
+ def _parse_ts_value(value):
2042
+ return _parse_ts({"_ts": value}) if value else None
2043
+
2044
+
2045
+ def _turn_timestamps(turn):
2046
+ values = []
2047
+ for item in [turn.get("user_message"), *turn.get("assistant_messages", [])]:
2048
+ dt = _parse_ts(item)
2049
+ if dt:
2050
+ values.append(dt)
2051
+ for item in [*turn.get("tool_calls", []), *turn.get("tool_results", [])]:
2052
+ dt = _parse_ts(item)
2053
+ if dt:
2054
+ values.append(dt)
2055
+ for sc in turn.get("subagent_calls", []):
2056
+ for key in ("_start_ts", "_end_ts"):
2057
+ dt = _parse_ts_value(sc.get(key))
2058
+ if dt:
2059
+ values.append(dt)
2060
+ return values
2061
+
2062
+
2063
+ def _turn_bounds(turn):
2064
+ values = _turn_timestamps(turn)
2065
+ if not values:
2066
+ return None, None
2067
+ return min(values), max(values)
2068
+
2069
+
2070
+ def _assistant_bounds(turn):
2071
+ values = []
2072
+ for item in turn.get("assistant_messages", []):
2073
+ dt = _parse_ts(item)
2074
+ if dt:
2075
+ values.append(dt)
2076
+ if not values:
2077
+ return None, None
2078
+ return min(values), max(values)
2079
+
2080
+
1622
2081
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1623
2082
  """Group raw JSONL entries into conversation turns.
1624
2083
 
@@ -1641,6 +2100,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1641
2100
  for entry in entries:
1642
2101
  entry_type = entry.get("type")
1643
2102
  payload = entry.get("payload", {})
2103
+ # 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
2104
+ if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
2105
+ payload["_ts"] = entry.get("timestamp")
1644
2106
 
1645
2107
  # --- Turn lifecycle events ---
1646
2108
  if entry_type == "event_msg":
@@ -1725,6 +2187,8 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1725
2187
  "message": args.get("message", ""),
1726
2188
  "model": args.get("model"),
1727
2189
  "result": None,
2190
+ "_start_ts": payload.get("_ts"),
2191
+ "_end_ts": None,
1728
2192
  }
1729
2193
  current_turn["subagent_calls"].append(subagent_call)
1730
2194
  pending_calls[call_id] = {"kind": "spawn", "subagent_call": subagent_call}
@@ -1732,14 +2196,16 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1732
2196
  pending_calls[call_id] = {
1733
2197
  "kind": "wait",
1734
2198
  "ids": args.get("ids", []),
2199
+ "_start_ts": payload.get("_ts"),
1735
2200
  }
1736
2201
  else:
1737
2202
  current_turn["tool_calls"].append({
1738
2203
  "call_id": call_id,
1739
2204
  "name": name,
1740
2205
  "input": args,
2206
+ "_ts": payload.get("_ts"),
1741
2207
  })
1742
- pending_calls[call_id] = {"kind": "tool"}
2208
+ pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
1743
2209
 
1744
2210
  elif item_type == "function_call_output":
1745
2211
  if current_turn is None:
@@ -1759,6 +2225,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1759
2225
  subagent_call["nickname"] = out.get("nickname")
1760
2226
  except (json.JSONDecodeError, TypeError, AttributeError):
1761
2227
  pass
2228
+ subagent_call["_end_ts"] = payload.get("_ts")
1762
2229
 
1763
2230
  elif kind == "wait":
1764
2231
  try:
@@ -1771,6 +2238,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1771
2238
  for sc in current_turn["subagent_calls"]:
1772
2239
  if sc.get("agent_id") == agent_id and sc.get("result") is None:
1773
2240
  sc["result"] = result_text
2241
+ sc["_end_ts"] = payload.get("_ts")
1774
2242
  break
1775
2243
  except (json.JSONDecodeError, TypeError, AttributeError):
1776
2244
  pass
@@ -1779,6 +2247,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
1779
2247
  current_turn["tool_results"].append({
1780
2248
  "call_id": call_id,
1781
2249
  "output": raw_output,
2250
+ "_ts": payload.get("_ts"),
1782
2251
  })
1783
2252
 
1784
2253
  if current_turn is not None:
@@ -1830,20 +2299,71 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1830
2299
  token = get_fresh_token()
1831
2300
  if token:
1832
2301
  os.environ["COZELOOP_API_TOKEN"] = token
1833
- client = cozeloop.new_client()
2302
+ hook_log(f"token resolved prefix={token[:12]}...")
2303
+ print(f"[CozeLoop] Token 获取成功 ({token[:12]}...)", file=sys.stderr)
2304
+ else:
2305
+ hook_log(
2306
+ "token missing "
2307
+ f"has_cozeloop_token={bool(os.environ.get('COZELOOP_API_TOKEN'))} "
2308
+ f"has_coze_token={bool(os.environ.get('COZE_API_TOKEN'))} "
2309
+ f"api_base_url={bool(get_api_base_url())}"
2310
+ )
2311
+ print("[CozeLoop] 警告: 未找到有效 Token,上报可能失败", file=sys.stderr)
2312
+ creds = _load_credentials()
2313
+ workspace_id = (creds or {}).get("workspace_id") or os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
2314
+ os.environ["COZELOOP_WORKSPACE_ID"] = workspace_id
2315
+ hook_log(f"workspace_id={workspace_id}")
2316
+ upload_events: List[str] = []
2317
+ client_kwargs = {
2318
+ "ultra_large_report": True,
2319
+ "upload_timeout": 120,
2320
+ "trace_finish_event_processor": _make_finish_event_processor(upload_events),
2321
+ }
2322
+ api_base_url = get_api_base_url()
2323
+ if api_base_url:
2324
+ client_kwargs["api_base_url"] = api_base_url
2325
+ hook_log(f"api_base_url={api_base_url}")
2326
+ if workspace_id:
2327
+ client_kwargs["workspace_id"] = workspace_id
2328
+ if token:
2329
+ client_kwargs["api_token"] = token
2330
+ client = cozeloop.new_client(**client_kwargs)
1834
2331
  ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
1835
2332
 
1836
2333
  try:
1837
- with client.start_span(name="codex_request", span_type="main") as root_span:
2334
+ # 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
2335
+ _all_ts = []
2336
+ for _t in turns:
2337
+ _all_ts.extend(_turn_timestamps(_t))
2338
+ root_start_dt = min(_all_ts) if _all_ts else None
2339
+ root_end_dt = max(_all_ts) if _all_ts else None
2340
+
2341
+ with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
1838
2342
  root_span.set_runtime(Runtime(library="codex-cli"))
1839
- root_span.set_tags({
2343
+ root_tags = {
1840
2344
  "thread_id": session_id,
1841
2345
  "total_turns": len(turns),
1842
2346
  "source": "codex_cli",
1843
- })
1844
- root_span.set_baggage({
2347
+ }
2348
+ if root_start_dt is not None and root_end_dt is not None:
2349
+ _set_finish_time_safe(root_span, root_end_dt)
2350
+ root_tags["real_start_ms"] = _ts_ms(root_start_dt)
2351
+ root_tags["real_end_ms"] = _ts_ms(root_end_dt)
2352
+ root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
2353
+ root_baggage = {
1845
2354
  "thread_id": session_id,
1846
- })
2355
+ }
2356
+ # Inject coze-context kv (last occurrence across turns wins).
2357
+ coze_tags = {}
2358
+ for turn in turns:
2359
+ t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
2360
+ if t:
2361
+ coze_tags = t
2362
+ if coze_tags:
2363
+ root_tags.update(coze_tags)
2364
+ root_baggage.update(coze_tags)
2365
+ root_span.set_tags(root_tags)
2366
+ root_span.set_baggage(root_baggage)
1847
2367
 
1848
2368
  # Set root span input: all user messages
1849
2369
  root_input_parts = []
@@ -1852,7 +2372,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1852
2372
  if text:
1853
2373
  root_input_parts.append(text)
1854
2374
  if root_input_parts:
1855
- root_span.set_input(truncate_text("\\n\\n".join(root_input_parts)))
2375
+ root_span.set_input(truncate_text("\n\n".join(root_input_parts)))
1856
2376
 
1857
2377
  # Set root span output: all assistant messages
1858
2378
  root_output_parts = []
@@ -1862,25 +2382,47 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1862
2382
  if assistant_text:
1863
2383
  root_output_parts.append(assistant_text)
1864
2384
  if root_output_parts:
1865
- root_span.set_output(truncate_text("\\n\\n".join(root_output_parts)))
2385
+ root_span.set_output(truncate_text("\n\n".join(root_output_parts)))
1866
2386
 
1867
2387
  # Process each turn
1868
2388
  for i, turn in enumerate(turns):
1869
2389
  try:
1870
- with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
2390
+ # turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
2391
+ turn_start_dt, turn_end_dt = _turn_bounds(turn)
2392
+
2393
+ with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
1871
2394
  turn_span.set_runtime(Runtime(library="codex-cli"))
1872
- turn_span.set_tags({
2395
+ _turn_tags = {
1873
2396
  "thread_id": session_id,
1874
2397
  "turn_index": i,
1875
2398
  "turn_id": turn.get("turn_id", ""),
1876
2399
  "source": "codex_cli",
1877
- })
2400
+ }
2401
+ if turn_start_dt is not None and turn_end_dt is not None:
2402
+ _set_finish_time_safe(turn_span, turn_end_dt)
2403
+ _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
2404
+ _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
2405
+ _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
2406
+ turn_span.set_tags(_turn_tags)
1878
2407
 
1879
2408
  # --- Model span for assistant response ---
1880
2409
  if turn.get("assistant_messages"):
1881
- with client.start_span(name="assistant_response", span_type="model") as model_span:
2410
+ # model span start:第一条 assistant payload 时间,回退到 turn 起点
2411
+ _model_start_dt, _model_end_dt = _assistant_bounds(turn)
2412
+ if _model_start_dt is None:
2413
+ _model_start_dt = turn_start_dt
2414
+ if _model_end_dt is None:
2415
+ _model_end_dt = turn_end_dt
2416
+ with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
1882
2417
  model_span.set_runtime(Runtime(library="codex-cli"))
1883
2418
  model_span.set_model_name(model_name)
2419
+ if _model_start_dt is not None and _model_end_dt is not None:
2420
+ _set_finish_time_safe(model_span, _model_end_dt)
2421
+ model_span.set_tags({
2422
+ "real_start_ms": _ts_ms(_model_start_dt),
2423
+ "real_end_ms": _ts_ms(_model_end_dt),
2424
+ "latency_ms": _ts_ms(_model_end_dt) - _ts_ms(_model_start_dt),
2425
+ })
1884
2426
 
1885
2427
  # Build input messages: history + current turn input
1886
2428
  turn_input = turn.get("input_messages", [])
@@ -1948,17 +2490,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1948
2490
  # --- Tool call spans ---
1949
2491
  for tool_call in turn.get("tool_calls", []):
1950
2492
  tool_name = tool_call.get("name", "unknown")
1951
- with client.start_span(name=f"tool_{tool_name}", span_type="tool") as tool_span:
2493
+ tool_start_dt = _parse_ts(tool_call) or turn_start_dt
2494
+ tool_end_dt = None
2495
+ call_id = tool_call.get("call_id")
2496
+ for result in turn.get("tool_results", []):
2497
+ if result.get("call_id") == call_id:
2498
+ tool_end_dt = _parse_ts(result)
2499
+ break
2500
+ tool_finish_dt = _max_dt(tool_end_dt, tool_start_dt)
2501
+ with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=tool_start_dt) as tool_span:
1952
2502
  tool_span.set_runtime(Runtime(library="codex-cli"))
1953
- tool_span.set_tags({
2503
+ tool_tags = {
1954
2504
  "tool_name": tool_name,
1955
- "call_id": tool_call.get("call_id"),
1956
- })
2505
+ "call_id": call_id,
2506
+ }
2507
+ if tool_start_dt is not None and tool_finish_dt is not None:
2508
+ _set_finish_time_safe(tool_span, tool_finish_dt)
2509
+ tool_tags["real_start_ms"] = _ts_ms(tool_start_dt)
2510
+ tool_tags["real_end_ms"] = _ts_ms(tool_finish_dt)
2511
+ tool_tags["latency_ms"] = _ts_ms(tool_finish_dt) - _ts_ms(tool_start_dt)
2512
+ tool_span.set_tags(tool_tags)
1957
2513
  tool_span.set_input(
1958
2514
  json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
1959
2515
  )
1960
2516
  # Find matching tool result
1961
- call_id = tool_call.get("call_id")
1962
2517
  for result in turn.get("tool_results", []):
1963
2518
  if result.get("call_id") == call_id:
1964
2519
  output = result.get("output", "")
@@ -1971,15 +2526,24 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1971
2526
  for sc in turn.get("subagent_calls", []):
1972
2527
  agent_id = sc.get("agent_id") or "unknown"
1973
2528
  nickname = sc.get("nickname") or agent_id
2529
+ subagent_start_dt = _parse_ts_value(sc.get("_start_ts")) or turn_start_dt
2530
+ subagent_end_dt = _parse_ts_value(sc.get("_end_ts")) or turn_end_dt
2531
+ subagent_finish_dt = _max_dt(subagent_end_dt, subagent_start_dt)
1974
2532
 
1975
- with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
2533
+ with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=subagent_start_dt) as subagent_span:
1976
2534
  subagent_span.set_runtime(Runtime(library="codex-cli"))
1977
- subagent_span.set_tags({
2535
+ subagent_tags = {
1978
2536
  "agent_id": agent_id,
1979
2537
  "agent_nickname": nickname,
1980
2538
  "agent_role": sc.get("role") or "",
1981
2539
  "agent_model": sc.get("model") or "",
1982
- })
2540
+ }
2541
+ if subagent_start_dt is not None and subagent_finish_dt is not None:
2542
+ _set_finish_time_safe(subagent_span, subagent_finish_dt)
2543
+ subagent_tags["real_start_ms"] = _ts_ms(subagent_start_dt)
2544
+ subagent_tags["real_end_ms"] = _ts_ms(subagent_finish_dt)
2545
+ subagent_tags["latency_ms"] = _ts_ms(subagent_finish_dt) - _ts_ms(subagent_start_dt)
2546
+ subagent_span.set_tags(subagent_tags)
1983
2547
  subagent_span.set_input(sc.get("message", "")[:2000])
1984
2548
 
1985
2549
  # Load and include saved subagent turn data
@@ -1989,20 +2553,45 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1989
2553
  sa_model = sa_data.get("model_name", "codex")
1990
2554
 
1991
2555
  for si, sa_turn in enumerate(sa_turns):
1992
- with client.start_span(name=f"turn_{si}", span_type="main") as sa_turn_span:
2556
+ sa_turn_start_dt, sa_turn_end_dt = _turn_bounds(sa_turn)
2557
+ if sa_turn_start_dt is None:
2558
+ sa_turn_start_dt = subagent_start_dt
2559
+ if sa_turn_end_dt is None:
2560
+ sa_turn_end_dt = sa_turn_start_dt
2561
+ sa_turn_finish_dt = _max_dt(sa_turn_end_dt, sa_turn_start_dt)
2562
+
2563
+ with client.start_span(name=f"turn_{si}", span_type="main", start_time=sa_turn_start_dt) as sa_turn_span:
1993
2564
  sa_turn_span.set_runtime(Runtime(library="codex-cli"))
1994
- sa_turn_span.set_tags({
2565
+ sa_turn_tags = {
1995
2566
  "turn_index": si,
1996
2567
  "turn_id": sa_turn.get("turn_id", ""),
1997
2568
  "agent_name": nickname,
1998
- })
2569
+ }
2570
+ if sa_turn_start_dt is not None and sa_turn_finish_dt is not None:
2571
+ _set_finish_time_safe(sa_turn_span, sa_turn_finish_dt)
2572
+ sa_turn_tags["real_start_ms"] = _ts_ms(sa_turn_start_dt)
2573
+ sa_turn_tags["real_end_ms"] = _ts_ms(sa_turn_finish_dt)
2574
+ sa_turn_tags["latency_ms"] = _ts_ms(sa_turn_finish_dt) - _ts_ms(sa_turn_start_dt)
2575
+ sa_turn_span.set_tags(sa_turn_tags)
1999
2576
 
2000
2577
  # Subagent model span
2001
2578
  if sa_turn.get("assistant_messages"):
2002
- with client.start_span(name="assistant_response", span_type="model") as sa_model_span:
2579
+ sa_model_start_dt, sa_model_end_dt = _assistant_bounds(sa_turn)
2580
+ if sa_model_start_dt is None:
2581
+ sa_model_start_dt = sa_turn_start_dt
2582
+ if sa_model_end_dt is None:
2583
+ sa_model_end_dt = sa_turn_end_dt
2584
+ sa_model_finish_dt = _max_dt(sa_model_end_dt, sa_model_start_dt)
2585
+ with client.start_span(name="assistant_response", span_type="model", start_time=sa_model_start_dt) as sa_model_span:
2003
2586
  sa_model_span.set_runtime(Runtime(library="codex-cli"))
2004
2587
  sa_model_span.set_model_name(sa_model)
2005
- sa_model_span.set_tags({"agent_name": nickname})
2588
+ sa_model_tags = {"agent_name": nickname}
2589
+ if sa_model_start_dt is not None and sa_model_finish_dt is not None:
2590
+ _set_finish_time_safe(sa_model_span, sa_model_finish_dt)
2591
+ sa_model_tags["real_start_ms"] = _ts_ms(sa_model_start_dt)
2592
+ sa_model_tags["real_end_ms"] = _ts_ms(sa_model_finish_dt)
2593
+ sa_model_tags["latency_ms"] = _ts_ms(sa_model_finish_dt) - _ts_ms(sa_model_start_dt)
2594
+ sa_model_span.set_tags(sa_model_tags)
2006
2595
 
2007
2596
  sa_input = sa_turn.get("input_messages", [])
2008
2597
  if not sa_input:
@@ -2046,17 +2635,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
2046
2635
  # Subagent tool spans
2047
2636
  for sa_tc in sa_turn.get("tool_calls", []):
2048
2637
  sa_tool_name = sa_tc.get("name", "unknown")
2049
- with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool") as sa_tool_span:
2638
+ sa_tool_start_dt = _parse_ts(sa_tc) or sa_turn_start_dt
2639
+ sa_tool_end_dt = None
2640
+ sa_cid = sa_tc.get("call_id")
2641
+ for sa_r in sa_turn.get("tool_results", []):
2642
+ if sa_r.get("call_id") == sa_cid:
2643
+ sa_tool_end_dt = _parse_ts(sa_r)
2644
+ break
2645
+ sa_tool_finish_dt = _max_dt(sa_tool_end_dt, sa_tool_start_dt)
2646
+ with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool", start_time=sa_tool_start_dt) as sa_tool_span:
2050
2647
  sa_tool_span.set_runtime(Runtime(library="codex-cli"))
2051
- sa_tool_span.set_tags({
2648
+ sa_tool_tags = {
2052
2649
  "tool_name": sa_tool_name,
2053
- "call_id": sa_tc.get("call_id"),
2650
+ "call_id": sa_cid,
2054
2651
  "agent_name": nickname,
2055
- })
2652
+ }
2653
+ if sa_tool_start_dt is not None and sa_tool_finish_dt is not None:
2654
+ _set_finish_time_safe(sa_tool_span, sa_tool_finish_dt)
2655
+ sa_tool_tags["real_start_ms"] = _ts_ms(sa_tool_start_dt)
2656
+ sa_tool_tags["real_end_ms"] = _ts_ms(sa_tool_finish_dt)
2657
+ sa_tool_tags["latency_ms"] = _ts_ms(sa_tool_finish_dt) - _ts_ms(sa_tool_start_dt)
2658
+ sa_tool_span.set_tags(sa_tool_tags)
2056
2659
  sa_tool_span.set_input(
2057
2660
  json.dumps(sa_tc.get("input", {}), ensure_ascii=False)[:2000]
2058
2661
  )
2059
- sa_cid = sa_tc.get("call_id")
2060
2662
  for sa_r in sa_turn.get("tool_results", []):
2061
2663
  if sa_r.get("call_id") == sa_cid:
2062
2664
  sa_out = sa_r.get("output", "")
@@ -2086,15 +2688,22 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
2086
2688
  debug_log(f"Error processing turn {i}: {e}")
2087
2689
  continue
2088
2690
 
2691
+ hook_log(f"processed turns={len(turns)} session_id={session_id}")
2089
2692
  debug_log(f"Successfully processed {len(turns)} turn(s) for session {session_id}")
2090
2693
 
2091
2694
  except Exception as e:
2695
+ hook_log(f"send exception={repr(e)}")
2092
2696
  debug_log(f"An error occurred while sending traces to CozeLoop: {e}")
2093
2697
  return None
2094
2698
  finally:
2095
2699
  client.close()
2700
+ hook_log("client closed")
2096
2701
  debug_log("CozeLoop client closed.")
2097
2702
 
2703
+ if upload_events:
2704
+ hook_log(f"upload failed state not advanced failures={len(upload_events)} detail={upload_events[-1][:500]}")
2705
+ return None
2706
+
2098
2707
  return ctx
2099
2708
 
2100
2709
 
@@ -2102,10 +2711,13 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
2102
2711
 
2103
2712
  def main():
2104
2713
  """Main entry point for the Codex CozeLoop hook."""
2714
+ print("[CozeLoop] Hook triggered (Codex).", file=sys.stderr)
2715
+ hook_log("hook triggered")
2105
2716
  debug_log("Codex CozeLoop hook started.")
2106
2717
 
2107
2718
  # Check if tracing is enabled
2108
2719
  if os.environ.get("TRACE_TO_COZELOOP", "").lower() == "false":
2720
+ hook_log("skip trace disabled")
2109
2721
  debug_log("TRACE_TO_COZELOOP is set to 'false', skipping")
2110
2722
  return
2111
2723
 
@@ -2113,24 +2725,41 @@ def main():
2113
2725
  try:
2114
2726
  raw_input = sys.stdin.read().strip()
2115
2727
  if not raw_input:
2728
+ hook_log("stdin empty, trying fallback")
2116
2729
  debug_log("No input received from stdin")
2117
- return
2118
- hook_input = json.loads(raw_input)
2730
+ hook_input = recover_hook_input("empty_stdin")
2731
+ if not hook_input:
2732
+ return
2733
+ else:
2734
+ hook_input = json.loads(raw_input)
2119
2735
  except Exception as e:
2736
+ hook_log(f"stdin parse error={repr(e)}, trying fallback")
2120
2737
  debug_log(f"Error reading hook input from stdin: {e}")
2121
- return
2738
+ hook_input = recover_hook_input("stdin_parse_error")
2739
+ if not hook_input:
2740
+ return
2122
2741
 
2123
2742
  debug_log(f"Hook input: {json.dumps(hook_input, ensure_ascii=False)}")
2124
2743
 
2125
2744
  # Get transcript path
2126
2745
  transcript_path = hook_input.get("transcript_path")
2127
2746
  if not transcript_path:
2747
+ hook_log("missing transcript_path, trying fallback")
2128
2748
  debug_log("No transcript_path in hook input")
2129
- return
2749
+ recovered = recover_hook_input("missing_transcript_path")
2750
+ if not recovered:
2751
+ return
2752
+ hook_input = recovered
2753
+ transcript_path = hook_input.get("transcript_path")
2130
2754
 
2131
2755
  if not os.path.exists(transcript_path):
2756
+ hook_log(f"transcript not found path={transcript_path}, trying fallback")
2132
2757
  debug_log(f"Transcript file not found: {transcript_path}")
2133
- return
2758
+ recovered = recover_hook_input("transcript_not_found")
2759
+ if not recovered:
2760
+ return
2761
+ hook_input = recovered
2762
+ transcript_path = hook_input.get("transcript_path")
2134
2763
 
2135
2764
  # Load state
2136
2765
  state_file = get_state_file_path(transcript_path)
@@ -2140,9 +2769,11 @@ def main():
2140
2769
  entries = read_rollout_messages(transcript_path, state["last_processed_line"])
2141
2770
 
2142
2771
  if not entries:
2772
+ hook_log(f"skip no new entries transcript={transcript_path}")
2143
2773
  debug_log("No new entries to process")
2144
2774
  return
2145
2775
 
2776
+ hook_log(f"read entries={len(entries)} from_line={state['last_processed_line']} transcript={transcript_path}")
2146
2777
  debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
2147
2778
 
2148
2779
  # Parse session identity
@@ -2192,11 +2823,20 @@ def main():
2192
2823
  last_line = max(e.get("_line_number", 0) for e in entries) + 1
2193
2824
  state["last_processed_line"] = last_line
2194
2825
  save_state(state_file, state)
2826
+ hook_log(f"subagent saved session_id={session_id} turns={len(turns[-1:])} last_line={last_line}")
2195
2827
  debug_log("Subagent data saved, hook completed")
2196
2828
  return
2197
2829
 
2198
- # Send turns to CozeLoop
2830
+ # Send turns to CozeLoop — only if at least one turn carries coze-context.
2199
2831
  if turns:
2832
+ has_coze_ctx = any(
2833
+ turn_coze_context(t)
2834
+ for t in turns
2835
+ )
2836
+ if not has_coze_ctx:
2837
+ hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
2838
+ debug_log("No coze-context found in any turn, skipping upload.")
2839
+ return
2200
2840
  history_context = state.get("conversation_history", [])
2201
2841
  updated_history = send_turns_to_cozeloop(
2202
2842
  turns, session_id, model_name,
@@ -2207,12 +2847,16 @@ def main():
2207
2847
  state["last_processed_line"] = last_line
2208
2848
  state["conversation_history"] = updated_history
2209
2849
  save_state(state_file, state)
2850
+ hook_log(f"state advanced last_line={last_line} session_id={session_id}")
2210
2851
  debug_log(f"State updated, last processed line: {last_line}")
2211
2852
  else:
2853
+ hook_log(f"send failed state not advanced session_id={session_id}")
2212
2854
  debug_log("Send failed, state not advanced")
2213
2855
  else:
2856
+ hook_log(f"skip no turns session_id={session_id}")
2214
2857
  debug_log("No turns to send")
2215
2858
 
2859
+ hook_log("hook completed")
2216
2860
  debug_log("Codex CozeLoop hook completed.")
2217
2861
 
2218
2862
 
@@ -3526,10 +4170,12 @@ async function _refreshToken(refreshTok) {
3526
4170
  try {
3527
4171
  const d = JSON.parse(buf);
3528
4172
  if (d.access_token) {
4173
+ const existing = _loadCreds() ?? {};
3529
4174
  const creds = {
3530
4175
  access_token: d.access_token,
3531
4176
  refresh_token: d.refresh_token ?? refreshTok,
3532
4177
  expires_at: (d.expires_in ?? 0) * 1000, // unix timestamp in seconds
4178
+ workspace_id: existing.workspace_id ?? "",
3533
4179
  };
3534
4180
  _saveCreds(creds);
3535
4181
  resolve(creds.access_token);
@@ -3553,7 +4199,7 @@ async function getRefreshedToken(currentAuthorization) {
3553
4199
  const newToken = await _refreshToken(creds.refresh_token);
3554
4200
  if (newToken) return \`Bearer \${newToken}\`;
3555
4201
  }
3556
- return currentAuthorization; // fallback
4202
+ return null;
3557
4203
  }
3558
4204
  // ─────────────────────────────────────────────────────────────────────────
3559
4205
 
@@ -3625,6 +4271,9 @@ export class CozeloopExporter {
3625
4271
  this.provider = null;
3626
4272
  this.tracer = null;
3627
4273
  }
4274
+ } else if (fresh === null) {
4275
+ this.api.logger.error("[CozeloopTrace] Local credentials exist but token refresh failed; refusing to reuse stale authorization.");
4276
+ this.config.authorization = "";
3628
4277
  }
3629
4278
  }
3630
4279
  async ensureInitialized() {
@@ -4138,13 +4787,19 @@ def refresh(rt):
4138
4787
  try:
4139
4788
  body = json.dumps({"grant_type":"refresh_token","client_id":CLIENT_ID,"refresh_token":rt}).encode()
4140
4789
  req = urllib.request.Request(f"{COZE_API}/api/permission/oauth2/token",
4141
- data=body, headers={"Content-Type":"application/json"})
4790
+ data=body, headers={
4791
+ "Content-Type":"application/json",
4792
+ "x-tt-env":"ppe_cozelab",
4793
+ "x-use-ppe":"1",
4794
+ })
4142
4795
  with urllib.request.urlopen(req, timeout=10) as r:
4143
4796
  d = json.loads(r.read())
4144
4797
  if d.get("access_token"):
4798
+ existing = load() or {}
4145
4799
  save({"access_token":d["access_token"],
4146
4800
  "refresh_token":d.get("refresh_token",rt),
4147
- "expires_at":d.get("expires_in",0)*1000})
4801
+ "expires_at":d.get("expires_in",0)*1000,
4802
+ "workspace_id":existing.get("workspace_id","")})
4148
4803
  return True
4149
4804
  except Exception as e:
4150
4805
  print(f"[cozeloop_refresh] refresh failed: {e}", file=sys.stderr)
@@ -4176,6 +4831,7 @@ function parseArgs() {
4176
4831
  if (arg === '--refresh') { args['refresh'] = true; continue; }
4177
4832
  if (arg === '--verify') { args['verify'] = true; continue; }
4178
4833
  if (arg === '--cloud') { args['cloud'] = true; continue; }
4834
+ if (arg === '--force') { args['force'] = true; continue; }
4179
4835
  const m = arg.match(/^--([^=]+)=(.+)$/);
4180
4836
  if (m) args[m[1]] = m[2];
4181
4837
  }
@@ -4237,6 +4893,7 @@ function validateArgs(args) {
4237
4893
  'codex-home': args['codex-home'],
4238
4894
  pairCode: args['pair-code'],
4239
4895
  cloud: true,
4896
+ force: !!args['force'],
4240
4897
  };
4241
4898
  }
4242
4899
  // config.json 缺失:回退到显式 --agent
@@ -4255,6 +4912,7 @@ function validateArgs(args) {
4255
4912
  'codex-home': args['codex-home'],
4256
4913
  pairCode: args['pair-code'],
4257
4914
  cloud: true,
4915
+ force: !!args['force'],
4258
4916
  };
4259
4917
  }
4260
4918
 
@@ -4269,6 +4927,7 @@ function validateArgs(args) {
4269
4927
  'codex-home': args['codex-home'],
4270
4928
  pairCode: args['pair-code'],
4271
4929
  cloud: !!args['cloud'],
4930
+ force: !!args['force'],
4272
4931
  };
4273
4932
  }
4274
4933
 
@@ -4280,6 +4939,9 @@ function validateArgs(args) {
4280
4939
  ' --agent=claude-code | codex | openclaw (全局配置)',
4281
4940
  ' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
4282
4941
  '',
4942
+ 'Flags:',
4943
+ ' --force 强制重装(OpenClaw 跳过幂等检查,无条件重写插件 + 重装依赖 + 重启 gateway)',
4944
+ '',
4283
4945
  'Other commands:',
4284
4946
  ' --status Show authorization status',
4285
4947
  ' --login Login (Device Code flow)',
@@ -4299,7 +4961,7 @@ function validateArgs(args) {
4299
4961
  ' --agent=openclaw',
4300
4962
  ]);
4301
4963
  }
4302
- return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'] };
4964
+ return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'], force: !!args['force'] };
4303
4965
  }
4304
4966
 
4305
4967
  // ─── 4. Agent detection ──────────────────────────────────────────────────────
@@ -4562,6 +5224,7 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
4562
5224
  if (cloud) {
4563
5225
  const loopToken = readEnv('COZELOOP_API_TOKEN');
4564
5226
  const cozeToken = readEnv('COZE_API_TOKEN');
5227
+ existing.env.COZELAB_ONBOARD_CLOUD = '1';
4565
5228
  if (loopToken) {
4566
5229
  existing.env.COZELOOP_API_TOKEN = loopToken;
4567
5230
  delete existing.env.COZE_API_TOKEN;
@@ -4570,8 +5233,9 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
4570
5233
  delete existing.env.COZELOOP_API_TOKEN;
4571
5234
  }
4572
5235
  } else {
4573
- existing.env.COZELOOP_API_TOKEN = patToken;
5236
+ delete existing.env.COZELOOP_API_TOKEN;
4574
5237
  delete existing.env.COZE_API_TOKEN;
5238
+ delete existing.env.COZELAB_ONBOARD_CLOUD;
4575
5239
  }
4576
5240
  const loopBaseUrl = readEnv('COZELOOP_API_BASE_URL');
4577
5241
  const otelEndpoint = readEnv('OTEL_ENDPOINT');
@@ -4600,8 +5264,8 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cl
4600
5264
 
4601
5265
  // writeCodexHook 把 hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
4602
5266
  // 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
4603
- // cloud=true cozeloop.env 不写死 token —— 云端 hook 运行时直接读环境变量
4604
- // COZE_API_TOKEN(见 scripts/codex/cozeloop_hook.py get_fresh_token)。
5267
+ // 本地模式不把短期 token 写入 cozeloop.env;Hook 运行时读取 ~/.cozeloop/credentials.json。
5268
+ // cloud=true 时写 COZELAB_ONBOARD_CLOUD,并带入 sandbox 注入的 trace token。
4605
5269
  function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
4606
5270
  const home = codexHome || path.join(os.homedir(), '.codex');
4607
5271
  const hooksDir = path.join(home, 'hooks');
@@ -4624,13 +5288,12 @@ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
4624
5288
  if (cloud) {
4625
5289
  const loopToken = readEnv('COZELOOP_API_TOKEN');
4626
5290
  const cozeToken = readEnv('COZE_API_TOKEN');
5291
+ envLines.push(shellEnvLine('COZELAB_ONBOARD_CLOUD', '1'));
4627
5292
  if (loopToken) {
4628
5293
  envLines.push(shellEnvLine('COZELOOP_API_TOKEN', loopToken));
4629
5294
  } else if (cozeToken) {
4630
5295
  envLines.push(shellEnvLine('COZE_API_TOKEN', cozeToken));
4631
5296
  }
4632
- } else {
4633
- envLines.push(shellEnvLine('COZELOOP_API_TOKEN', token));
4634
5297
  }
4635
5298
  envLines.push(shellEnvLine('CODEX_HOME', home));
4636
5299
  envLines.push(shellEnvLine('COZELOOP_HOOK_LOG', logFile));
@@ -4740,7 +5403,7 @@ function normalizeCozeloopApiBaseUrl(raw) {
4740
5403
  return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
4741
5404
  }
4742
5405
  if (base.endsWith('/api/v1/loop/opentelemetry')) {
4743
- return base.slice(0, -'/loop/opentelemetry'.length).replace(/\/+$/, '');
5406
+ return base.slice(0, -'/v1/loop/opentelemetry'.length).replace(/\/+$/, '');
4744
5407
  }
4745
5408
  if (base.endsWith('/api/v1')) {
4746
5409
  return base.slice(0, -'/v1'.length).replace(/\/+$/, '');
@@ -4790,6 +5453,7 @@ function applyOpenClawPluginConfig(existing, token, workspaceId, agentId, cloud)
4790
5453
  pcfg.endpoint = getOtelEndpointBase(cloud);
4791
5454
  pcfg.workspaceId = workspaceId;
4792
5455
  pcfg.debug = true;
5456
+ pcfg.disableLocalCredentials = !!cloud;
4793
5457
  // 插件代码版本:参与幂等比对,升级插件(bump scripts/openclaw/package.json version)后
4794
5458
  // 强制触发重写+重装+重启,避免云端 pluginDir 滞留旧插件 dist。
4795
5459
  if (OPENCLAW_PLUGIN_VERSION) {
@@ -5155,6 +5819,147 @@ async function verifyTraceReport(token, workspaceId, pairCode, tracesUrl) {
5155
5819
  return { success, status: res.status, body: res.body, traceId, pairCode: pair };
5156
5820
  }
5157
5821
 
5822
+ // ── OpenClaw 专属上报链路校验 ──────────────────────────────────────────────
5823
+ // 为什么单独一条:claude-code/codex 的 verify 用主流程刚 getValidToken() 刷新过的有效
5824
+ // token 直发,而 openclaw 运行时上报用的是【写死在 openclaw.json 插件 config.authorization
5825
+ // 里的静态 token】。两者是不同 token —— 插件那个失效(401/4100)时通用 verify 照样 ok,
5826
+ // 这就是“verify=ok 但实际查不到 trace”假象的根因。本函数改为读插件实际配置的 token 打
5827
+ // ingest,真实反映运行时会不会 401。
5828
+ //
5829
+ // cloud/local 兼容:插件配置位置都在 resolveHomeDir(cloud)/.openclaw/openclaw.json,
5830
+ // endpoint 都走 getOtelEndpointBase(cloud),逻辑统一。差异在 token 刷新:
5831
+ // - local:disableLocalCredentials=false,插件会读 ~/.cozeloop/credentials.json 自动刷新,
5832
+ // 所以额外检测【实际加载的插件是否含刷新逻辑 getRefreshedToken】,无则告警。
5833
+ // - cloud:disableLocalCredentials=true,插件只用写死的 token、不刷新,token 失效需重注入,
5834
+ // 刷新能力检测对 cloud 无意义(跳过)。
5835
+ async function verifyOpenClawTraceLink(cloud) {
5836
+ const home = resolveHomeDir(cloud);
5837
+ const configPath = path.join(home, '.openclaw', 'openclaw.json');
5838
+ let pcfg = null;
5839
+ try {
5840
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
5841
+ pcfg = cfg?.plugins?.entries?.['openclaw-cozeloop-trace']?.config || null;
5842
+ } catch (e) {
5843
+ warn(`无法读取 openclaw.json 插件配置: ${e.message}`);
5844
+ return { success: false, status: 0, body: `read ${configPath} failed: ${e.message}` };
5845
+ }
5846
+ if (!pcfg || !pcfg.authorization) {
5847
+ warn('openclaw 插件未配置 authorization,无法校验上报链路。');
5848
+ return { success: false, status: 0, body: 'plugin authorization missing' };
5849
+ }
5850
+
5851
+ // 1) 用插件【实际配置的】token 打一条最小 OTLP trace ——这才是运行时真实用的那个 token。
5852
+ // 不能发空 resourceSpans,CozeLoop 会返回 "unknown event type",导致自检假失败。
5853
+ const authHeader = pcfg.authorization; // 形如 "Bearer czu_xxx"
5854
+ const tracesUrl = (pcfg.endpoint
5855
+ ? `${String(pcfg.endpoint).replace(/\/+$/, '')}/v1/traces`
5856
+ : getOtelTracesUrl(cloud));
5857
+ const workspaceId = pcfg.workspaceId || WORKSPACE_ID;
5858
+ const traceId = crypto.randomBytes(16).toString('hex');
5859
+ const spanId = crypto.randomBytes(8).toString('hex');
5860
+ const nowNs = String(Date.now() * 1_000_000);
5861
+ const pair = crypto.randomBytes(6).toString('hex');
5862
+ const otlpBody = {
5863
+ resourceSpans: [{
5864
+ resource: {
5865
+ attributes: [
5866
+ { key: 'service.name', value: { stringValue: 'cozelab-onboard-openclaw' } },
5867
+ ],
5868
+ },
5869
+ scopeSpans: [{
5870
+ scope: { name: 'cozelab-onboard-openclaw-selfcheck' },
5871
+ spans: [{
5872
+ traceId,
5873
+ spanId,
5874
+ name: 'cozelab-onboard-openclaw-selfcheck',
5875
+ kind: 1,
5876
+ startTimeUnixNano: nowNs,
5877
+ endTimeUnixNano: nowNs,
5878
+ status: { code: 1 },
5879
+ attributes: [
5880
+ { key: 'pair_code', value: { stringValue: pair } },
5881
+ { key: 'source', value: { stringValue: 'cozelab-onboard-openclaw' } },
5882
+ ],
5883
+ }],
5884
+ }],
5885
+ }],
5886
+ };
5887
+ let res;
5888
+ try {
5889
+ res = await httpsPost(
5890
+ tracesUrl,
5891
+ otlpBody,
5892
+ { Authorization: authHeader, 'cozeloop-workspace-id': String(workspaceId) },
5893
+ );
5894
+ } catch (e) {
5895
+ warn(`openclaw 插件 token 上报探测失败: ${e.message}`);
5896
+ return { success: false, status: 0, body: e.message };
5897
+ }
5898
+ const success = res.status >= 200 && res.status < 300;
5899
+ const tokenPrefix = authHeader.replace(/^Bearer\s+/i, '').slice(0, 12);
5900
+
5901
+ if (success) {
5902
+ ok(`openclaw 插件实际 token 上报正常 (token=${tokenPrefix}..., HTTP ${res.status})`);
5903
+ } else {
5904
+ warn(`openclaw 插件实际 token 上报失败: HTTP ${res.status} (token=${tokenPrefix}...)`);
5905
+ const snippet = (res.body || '').slice(0, 300);
5906
+ if (snippet) console.log(snippet);
5907
+ // 4100/401 = 该 token 已失效。指出根因与修复方式。
5908
+ if (res.status === 401 || /\b4100\b/.test(res.body || '')) {
5909
+ info('插件配置的 token 已失效。运行时上报会 401 → OTLP 抛 unhandled rejection → gateway 崩溃 → span 丢失。');
5910
+ info('修复:重跑 `node index.js --agent=openclaw --force` 写入新 token(local 会从 ~/.cozeloop 自动刷新)。');
5911
+ }
5912
+ }
5913
+
5914
+ // 2) 仅 local:检测实际加载的插件是否具备 token 自动刷新能力。
5915
+ // cloud 主动 disableLocalCredentials,不刷新,检测无意义。
5916
+ if (!cloud) {
5917
+ const refreshOk = openClawPluginHasRefresh(home);
5918
+ if (refreshOk === true) {
5919
+ ok('openclaw 插件具备 token 自动刷新能力 (getRefreshedToken)。');
5920
+ } else if (refreshOk === false) {
5921
+ warn('本机加载的 openclaw 插件【无 token 刷新逻辑】,token 过期后会反复 401 崩 gateway。');
5922
+ info('修复:重跑 `node index.js --agent=openclaw --force` 安装带刷新逻辑的新插件。');
5923
+ }
5924
+ // refreshOk === null:定位不到插件文件,不下结论(不误报)。
5925
+ }
5926
+
5927
+ return { success, status: res.status, body: res.body || '' };
5928
+ }
5929
+
5930
+ // 检测本机【实际加载的】openclaw trace 插件是否含 token 刷新逻辑(getRefreshedToken)。
5931
+ // 返回 true=有 / false=无 / null=定位不到插件文件(不下结论)。
5932
+ // 探测顺序:openclaw plugins list 给出的真实路径 > onboard 安装位置 ~/.cozeloop/openclaw-plugin
5933
+ // > 历史手改位置 ~/.openclaw/workspace/cozeloop-trace-fix。
5934
+ function openClawPluginHasRefresh(home) {
5935
+ const candidates = [];
5936
+ // openclaw plugins list 拿实际加载路径(最准——能发现 cozeloop-trace-fix 这类残留旧插件)
5937
+ try {
5938
+ const { execSync } = require('child_process');
5939
+ const out = execSync('openclaw plugins list', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
5940
+ for (const line of out.split(/\r?\n/)) {
5941
+ if (!/cozeloop|trace/i.test(line)) continue;
5942
+ const m = line.match(/(\/[^\s'"]+)/); // 抓行内绝对路径
5943
+ if (m) candidates.push(m[1]);
5944
+ }
5945
+ } catch { /* CLI 不可用则回退已知路径 */ }
5946
+ candidates.push(path.join(home, '.cozeloop', 'openclaw-plugin'));
5947
+ candidates.push(path.join(home, '.openclaw', 'workspace', 'cozeloop-trace-fix'));
5948
+
5949
+ let foundAny = false;
5950
+ for (const base of candidates) {
5951
+ for (const rel of ['dist/cozeloop-exporter.js', 'dist/index.js', 'cozeloop-exporter.js', 'index.js']) {
5952
+ const f = path.isAbsolute(rel) ? rel : path.join(base, rel);
5953
+ try {
5954
+ if (!fs.existsSync(f)) continue;
5955
+ foundAny = true;
5956
+ if (fs.readFileSync(f, 'utf8').includes('getRefreshedToken')) return true;
5957
+ } catch { /* ignore */ }
5958
+ }
5959
+ }
5960
+ return foundAny ? false : null;
5961
+ }
5962
+
5158
5963
  function httpsGet(url, headers) {
5159
5964
  return new Promise((resolve, reject) => {
5160
5965
  // 合并 PPE 泳道 header
@@ -5486,7 +6291,22 @@ async function main() {
5486
6291
  const token = await getValidToken(); // 无凭证会自动走登录/刷新
5487
6292
  console.log('');
5488
6293
  const result = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
5489
- process.exit(result.success ? 0 : 1);
6294
+ // 若本机装了 openclaw 插件,额外校验插件【实际配置的静态 token】——通用 verify 用刚刷新的
6295
+ // token 测不到它。任一失败则整体判失败(exit 1)。
6296
+ let ocOk = true;
6297
+ const ocConfigPath = path.join(resolveHomeDir(false), '.openclaw', 'openclaw.json');
6298
+ if (fs.existsSync(ocConfigPath)) {
6299
+ try {
6300
+ const cfg = JSON.parse(fs.readFileSync(ocConfigPath, 'utf8'));
6301
+ if (cfg?.plugins?.entries?.['openclaw-cozeloop-trace']?.config?.authorization) {
6302
+ console.log('');
6303
+ info('检测到 openclaw cozeloop-trace 插件,校验其实际 token...');
6304
+ const ocRes = await verifyOpenClawTraceLink(false);
6305
+ ocOk = ocRes.success;
6306
+ }
6307
+ } catch { /* 读不了就跳过 openclaw 校验 */ }
6308
+ }
6309
+ process.exit(result.success && ocOk ? 0 : 1);
5490
6310
  }
5491
6311
 
5492
6312
  const { agent } = args;
@@ -5616,9 +6436,15 @@ async function main() {
5616
6436
 
5617
6437
  // Step 5: Verify trace reporting end-to-end
5618
6438
  info('Step 5/5: 验证 trace 上报链路...');
5619
- const verifyResult = args.cloud
5620
- ? await verifyTraceReportViaSdk(token, WORKSPACE_ID, args.pairCode, pythonCmd || 'python3', tokenSource)
5621
- : await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
6439
+ // openclaw 走专属校验:claude-code/codex 的 verify 用主流程刚 getValidToken() 刷新过的
6440
+ // 有效 token 直发,测不到 openclaw 插件【写死在 openclaw.json 里的静态 token】是否失效
6441
+ // (插件不读这个临时 token)。openclaw 必须用插件实际配置的 authorization 打 ingest,
6442
+ // 才能真实反映运行时上报会不会 401。cloud/local 配置位置一致,统一走这条。
6443
+ const verifyResult = agent === 'openclaw'
6444
+ ? await verifyOpenClawTraceLink(args.cloud)
6445
+ : args.cloud
6446
+ ? await verifyTraceReportViaSdk(token, WORKSPACE_ID, args.pairCode, pythonCmd || 'python3', tokenSource)
6447
+ : await verifyTraceReport(token, WORKSPACE_ID, args.pairCode, getOtelTracesUrl(false));
5622
6448
  if (verifyResult.success) {
5623
6449
  cloudResult.verify = 'ok';
5624
6450
  } else if (CLOUD_MODE) {