coze_lab 0.1.3 → 0.1.5

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
@@ -3,7 +3,10 @@
3
3
 
4
4
  // ─── 0. Constants ─────────────────────────────────────────────────────────────
5
5
  const CLIENT_ID = '46371084383473718052118955183420.app.coze';
6
- const WORKSPACE_ID = '7645949103524380682';
6
+ const WORKSPACE_ID = '7644910356078837760';
7
+ // PPE 泳道:所有 cozeloop 上报 / OAuth 请求都带这两个 header(SDK 读 x_tt_env/x_use_ppe 环境变量自动注入)。
8
+ const PPE_TT_ENV = 'ppe_cozelab';
9
+ const PPE_USE_PPE = '1';
7
10
  const COZE_API = 'https://api.coze.cn';
8
11
  const CREDS_PATH = require('path').join(require('os').homedir(), '.cozeloop', 'credentials.json');
9
12
  // Refresh when less than 10 minutes remain
@@ -3455,7 +3458,7 @@ async function _refreshToken(refreshTok) {
3455
3458
  const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
3456
3459
  const req = https.request(\`\${_COZE_API}/api/permission/oauth2/token\`, {
3457
3460
  method: "POST",
3458
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
3461
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
3459
3462
  }, (res) => {
3460
3463
  let buf = "";
3461
3464
  res.on("data", c => buf += c);
@@ -3589,6 +3592,8 @@ export class CozeloopExporter {
3589
3592
  headers: {
3590
3593
  "Authorization": authorization,
3591
3594
  "cozeloop-workspace-id": workspaceId,
3595
+ "x-tt-env": "ppe_cozelab",
3596
+ "x-use-ppe": "1",
3592
3597
  },
3593
3598
  });
3594
3599
  this.provider = new BasicTracerProvider({ resource });
@@ -4117,20 +4122,61 @@ function parseArgs() {
4117
4122
 
4118
4123
  const VALID_AGENTS = ['claude-code', 'codex', 'openclaw'];
4119
4124
 
4125
+ // resolveAgent 读 ~/.coze/agents/<agentId>/config.json,返回 { framework, workspace, agentId, root }。
4126
+ // 目录或 config 不存在、framework 非法时直接报错退出。
4127
+ function resolveAgent(agentId) {
4128
+ const root = path.join(os.homedir(), '.coze', 'agents', agentId);
4129
+ const configPath = path.join(root, 'config.json');
4130
+ if (!fs.existsSync(configPath)) {
4131
+ errorBox([
4132
+ `ERROR: agent "${agentId}" 不存在`,
4133
+ '',
4134
+ `未找到 ${configPath}`,
4135
+ '请确认 agentId 正确,或先用 coze-bridge 创建该 agent。',
4136
+ ]);
4137
+ }
4138
+ let cfg;
4139
+ try {
4140
+ cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
4141
+ } catch (e) {
4142
+ errorBox([`ERROR: 解析 ${configPath} 失败`, '', e.message]);
4143
+ }
4144
+ const framework = cfg.framework;
4145
+ if (!VALID_AGENTS.includes(framework)) {
4146
+ errorBox([
4147
+ `ERROR: agent "${agentId}" 的 framework="${framework}" 不受支持`,
4148
+ '',
4149
+ `支持的类型: ${VALID_AGENTS.join(', ')}`,
4150
+ ]);
4151
+ }
4152
+ return { framework, workspace: cfg.workspace || '', agentId, root };
4153
+ }
4154
+
4120
4155
  function validateArgs(args) {
4121
4156
  if (args['logout']) return { logout: true };
4122
4157
  if (args['login']) return { login: true };
4123
4158
  if (args['status']) return { status: true };
4124
4159
  if (args['refresh']) return { refresh: true };
4125
4160
 
4161
+ // 优先 --agent-id:读 ~/.coze/agents/<id>/config.json 的 framework 自动路由。
4162
+ if (args['agent-id']) {
4163
+ const resolved = resolveAgent(args['agent-id']);
4164
+ return {
4165
+ agent: resolved.framework,
4166
+ agentId: resolved.agentId,
4167
+ workspace: resolved.workspace,
4168
+ agentRoot: resolved.root,
4169
+ 'codex-home': args['codex-home'],
4170
+ };
4171
+ }
4172
+
4126
4173
  if (!args['agent']) {
4127
4174
  errorBox([
4128
- 'ERROR: --agent is required',
4175
+ 'ERROR: --agent --agent-id 至少提供一个',
4129
4176
  '',
4130
4177
  'Usage:',
4131
- ' --agent=claude-code',
4132
- ' --agent=codex',
4133
- ' --agent=openclaw',
4178
+ ' --agent=claude-code | codex | openclaw (全局配置)',
4179
+ ' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
4134
4180
  '',
4135
4181
  'Other commands:',
4136
4182
  ' --status Show authorization status',
@@ -4149,7 +4195,7 @@ function validateArgs(args) {
4149
4195
  ' --agent=openclaw',
4150
4196
  ]);
4151
4197
  }
4152
- return { agent: args['agent'] };
4198
+ return { agent: args['agent'], 'codex-home': args['codex-home'] };
4153
4199
  }
4154
4200
 
4155
4201
  // ─── 4. Agent detection ──────────────────────────────────────────────────────
@@ -4337,22 +4383,31 @@ function mergeJson(filepath, mergeFn) {
4337
4383
  return mergeFn(existing);
4338
4384
  }
4339
4385
 
4340
- function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4386
+ // writeClaudeCodeHook 配置 Claude Code 的 hook。
4387
+ // configBaseDir 缺省 process.cwd()(全局/项目级);传入 agent 的 workspace 则 per-agent:
4388
+ // settings.json + 凭证写进 <configBaseDir>/.claude,仅该 agent(以此为 cwd 启动)生效。
4389
+ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir) {
4341
4390
  const hooksDir = path.join(os.homedir(), '.claude', 'hooks');
4342
4391
  const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
4343
- const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
4344
- const localSettingsPath = path.join(process.cwd(), '.claude', 'settings.local.json');
4345
-
4346
- // 1. Write Python hook scripts (trace + refresh)
4392
+ const baseDir = configBaseDir || process.cwd();
4393
+ // per-agent settings.json 写进 agent 的 .claude;全局时仍写 ~/.claude/settings.json
4394
+ const claudeDir = configBaseDir
4395
+ ? path.join(baseDir, '.claude')
4396
+ : path.join(os.homedir(), '.claude');
4397
+ const settingsPath = path.join(claudeDir, 'settings.json');
4398
+ const localSettingsPath = path.join(baseDir, '.claude', 'settings.local.json');
4399
+
4400
+ // 1. Write Python hook scripts (trace + refresh) — 脚本放全局 ~/.claude/hooks,可共享
4347
4401
  ensureDir(hooksDir);
4348
4402
  writeHookScript(hookScript, readScript('claude-code/cozeloop_hook.py'));
4349
4403
  const refreshScript = path.join(hooksDir, 'cozeloop_refresh.py');
4350
4404
  writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
4351
4405
 
4352
- // 2. Merge global settings.json — Stop (trace) + UserPromptSubmit (refresh)
4353
- const hookCmd = `${pythonCmd} ~/.claude/hooks/cozeloop_hook.py`;
4354
- const refreshCmd = `${pythonCmd} ~/.claude/hooks/cozeloop_refresh.py`;
4406
+ // 2. Merge settings.json — Stop (trace) + UserPromptSubmit (refresh)。用绝对路径。
4407
+ const hookCmd = `${pythonCmd} ${hookScript}`;
4408
+ const refreshCmd = `${pythonCmd} ${refreshScript}`;
4355
4409
 
4410
+ ensureDir(claudeDir);
4356
4411
  const settings = mergeJson(settingsPath, (existing) => {
4357
4412
  if (!existing.hooks) existing.hooks = {};
4358
4413
 
@@ -4377,14 +4432,17 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4377
4432
  } catch (e) {
4378
4433
  errorBox([`ERROR: Cannot write ${settingsPath}`, '', e.message]);
4379
4434
  }
4380
- ok(`Global hook registered in ${settingsPath}`);
4435
+ ok(`Hook registered in ${settingsPath}`);
4381
4436
 
4382
- // 3. Write project-level credentials
4383
- ensureDir(path.join(process.cwd(), '.claude'));
4437
+ // 3. Write credentials into <baseDir>/.claude/settings.local.json
4438
+ ensureDir(path.join(baseDir, '.claude'));
4384
4439
  const localSettings = mergeJson(localSettingsPath, (existing) => {
4385
4440
  if (!existing.env) existing.env = {};
4386
4441
  existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
4387
4442
  existing.env.COZELOOP_API_TOKEN = patToken;
4443
+ // PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
4444
+ existing.env.x_tt_env = PPE_TT_ENV;
4445
+ existing.env.x_use_ppe = PPE_USE_PPE;
4388
4446
  return existing;
4389
4447
  });
4390
4448
  try {
@@ -4398,11 +4456,14 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4398
4456
  return { hookScript, settingsPath, localSettingsPath };
4399
4457
  }
4400
4458
 
4401
- function writeCodexHook(token, workspaceId, pythonCmd) {
4402
- const hooksDir = path.join(os.homedir(), '.codex', 'hooks');
4459
+ // writeCodexHook hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
4460
+ // 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
4461
+ function writeCodexHook(token, workspaceId, pythonCmd, codexHome) {
4462
+ const home = codexHome || path.join(os.homedir(), '.codex');
4463
+ const hooksDir = path.join(home, 'hooks');
4403
4464
  const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
4404
4465
  const envFile = path.join(hooksDir, 'cozeloop.env');
4405
- const hooksJson = path.join(os.homedir(), '.codex', 'hooks.json');
4466
+ const hooksJson = path.join(home, 'hooks.json');
4406
4467
 
4407
4468
  // 1. Write Python hook scripts (trace + refresh)
4408
4469
  // Token is read from ~/.cozeloop/credentials.json at runtime
@@ -4416,6 +4477,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4416
4477
  `COZELOOP_WORKSPACE_ID=${workspaceId}`,
4417
4478
  `COZELOOP_API_TOKEN=${token}`,
4418
4479
  'TRACE_TO_COZELOOP=true',
4480
+ // PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
4481
+ `x_tt_env=${PPE_TT_ENV}`,
4482
+ `x_use_ppe=${PPE_USE_PPE}`,
4419
4483
  ].join('\n') + '\n';
4420
4484
  try {
4421
4485
  fs.writeFileSync(envFile, envContent, { mode: 0o600 });
@@ -4425,8 +4489,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4425
4489
  ok(`Credentials written to ${envFile} (chmod 600)`);
4426
4490
 
4427
4491
  // 3. Merge hooks.json — Stop (trace) + SessionStart (refresh)
4428
- const hookCmd = `set -a && . ~/.codex/hooks/cozeloop.env && set +a && ${pythonCmd} ~/.codex/hooks/cozeloop_hook.py`;
4429
- const refreshCmd = `${pythonCmd} ~/.codex/hooks/cozeloop_refresh.py`;
4492
+ // 命令用绝对路径(CODEX_HOME 不一定是 ~/.codex)。
4493
+ const hookCmd = `set -a && . ${envFile} && set +a && ${pythonCmd} ${hookScript}`;
4494
+ const refreshCmd = `${pythonCmd} ${refreshScript}`;
4430
4495
 
4431
4496
  const hooks = mergeJson(hooksJson, (existing) => {
4432
4497
  if (!existing.hooks) existing.hooks = {};
@@ -4551,6 +4616,17 @@ function writeOpenClawHook(token, workspaceId) {
4551
4616
  errorBox([`ERROR: Cannot write ${configPath}`, '', e.message]);
4552
4617
  }
4553
4618
  ok(`OpenClaw plugin configured in ${configPath}`);
4619
+
4620
+ // 自动重启 gateway,让新插件配置立即生效(不依赖 plugins install 的隐式重启)。
4621
+ try {
4622
+ const { execSync } = require('child_process');
4623
+ info('Restarting OpenClaw gateway to apply hook changes...');
4624
+ execSync('openclaw gateway restart', { stdio: 'pipe' });
4625
+ ok('OpenClaw gateway restarted');
4626
+ } catch (e) {
4627
+ warn(`gateway restart 失败,请手动执行: openclaw gateway restart(${e.message})`);
4628
+ }
4629
+
4554
4630
  return { configPath, pluginDir };
4555
4631
  }
4556
4632
 
@@ -4563,7 +4639,8 @@ function httpsPost(url, body) {
4563
4639
  const u = new URL(url);
4564
4640
  const req = https.request(
4565
4641
  { hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
4566
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
4642
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
4643
+ 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE } },
4567
4644
  (res) => {
4568
4645
  let buf = '';
4569
4646
  res.on('data', c => buf += c);
@@ -4579,7 +4656,9 @@ function httpsPost(url, body) {
4579
4656
 
4580
4657
  function httpsGet(url, headers) {
4581
4658
  return new Promise((resolve, reject) => {
4582
- const req = https.get(url, { headers }, (res) => {
4659
+ // 合并 PPE 泳道 header
4660
+ const h = { ...(headers || {}), 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE };
4661
+ const req = https.get(url, { headers: h }, (res) => {
4583
4662
  let buf = '';
4584
4663
  res.on('data', c => buf += c);
4585
4664
  res.on('end', () => resolve({ status: res.statusCode, body: buf }));
@@ -4845,9 +4924,9 @@ async function verifyWorkspace(token) {
4845
4924
 
4846
4925
  // ─── 9. Main ─────────────────────────────────────────────────────────────────
4847
4926
  const NEXT_STEP = {
4848
- 'claude-code': 'Start a new Claude Code session to begin tracing.',
4849
- 'codex': 'Start a new Codex session to begin tracing.',
4850
- 'openclaw': 'Restart OpenClaw to begin tracing.',
4927
+ 'claude-code': 'Hook 已写入。请在当前 Claude Code 会话运行 /new(或重开会话)使 hook 生效——外部进程无法替你 /new。',
4928
+ 'codex': 'Hook 已写入。请重开 Codex 会话使 hook 生效——外部进程无法替你重启当前会话。',
4929
+ 'openclaw': 'OpenClaw gateway 已自动重启,trace 即刻生效。',
4851
4930
  };
4852
4931
 
4853
4932
  async function main() {
@@ -4913,17 +4992,22 @@ async function main() {
4913
4992
  }
4914
4993
 
4915
4994
  const { agent } = args;
4995
+ // per-agent 路由时 agent 的 workspace(claude-code 用它做配置根目录)。
4996
+ const agentWorkspace = args.workspace || '';
4997
+ if (args.agentId) {
4998
+ info(`目标 agent: ${args.agentId} (framework=${agent}, workspace=${agentWorkspace || 'N/A'})`);
4999
+ console.log('');
5000
+ }
4916
5001
 
4917
5002
  // Step 1: Authorize (load cached → refresh → device code)
4918
- // Step 2: Verify token works — if 401, refresh/re-auth and retry once
4919
5003
  info('Step 1/5: 检查授权状态...');
4920
5004
  let token = await getValidToken();
4921
5005
  console.log('');
4922
5006
 
5007
+ // Step 2: Verify token works
4923
5008
  info('Step 2/5: 验证工作空间权限...');
4924
5009
  let verified = await verifyWorkspace(token);
4925
5010
  if (verified === null) {
4926
- // Token rejected by API — clear cached creds and re-authorize
4927
5011
  warn('Token 已失效,正在重新授权...');
4928
5012
  deleteCredentials();
4929
5013
  console.log('');
@@ -4952,27 +5036,33 @@ async function main() {
4952
5036
 
4953
5037
  // Step 5: Write hook configuration
4954
5038
  info('Step 5/5: Writing hook configuration...');
5039
+ let written = {};
4955
5040
  if (agent === 'claude-code') {
4956
- writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd);
5041
+ // per-agent 路由时把配置写进 agent 的 workspace(仅该 agent 生效)。
5042
+ written = writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd, args.agentId ? agentWorkspace : undefined);
4957
5043
  } else if (agent === 'codex') {
4958
- writeCodexHook(token, WORKSPACE_ID, pythonCmd);
5044
+ // CODEX_HOME 来源优先级:--codex-home= > 环境变量 CODEX_HOME > ~/.codex(缺省)。
5045
+ const codexHome = args['codex-home'] || process.env.CODEX_HOME || undefined;
5046
+ if (codexHome) info(`Codex 配置目录: ${codexHome}`);
5047
+ written = writeCodexHook(token, WORKSPACE_ID, pythonCmd, codexHome);
4959
5048
  } else {
4960
- writeOpenClawHook(token, WORKSPACE_ID);
5049
+ written = writeOpenClawHook(token, WORKSPACE_ID) || {};
4961
5050
  }
4962
5051
  console.log('');
4963
5052
 
4964
- // Success summary
5053
+ // Success summary(用实际写入路径,per-agent / 自定义 CODEX_HOME 时也准确)
4965
5054
  const summaryLines = [
4966
5055
  `Agent: ${agent} v${version}`,
4967
5056
  ];
5057
+ if (args.agentId) summaryLines.push(`Agent ID: ${args.agentId}`);
4968
5058
  if (agent === 'claude-code') {
4969
- summaryLines.push(`Hook script: ~/.claude/hooks/cozeloop_hook.py`);
4970
- summaryLines.push(`Config: ~/.claude/settings.json`);
4971
- summaryLines.push(`Credentials: .claude/settings.local.json`);
5059
+ summaryLines.push(`Hook script: ${written.hookScript || '~/.claude/hooks/cozeloop_hook.py'}`);
5060
+ summaryLines.push(`Config: ${written.settingsPath || '~/.claude/settings.json'}`);
5061
+ summaryLines.push(`Credentials: ${written.localSettingsPath || '.claude/settings.local.json'}`);
4972
5062
  } else if (agent === 'codex') {
4973
- summaryLines.push(`Hook script: ~/.codex/hooks/cozeloop_hook.py`);
4974
- summaryLines.push(`Config: ~/.codex/hooks.json`);
4975
- summaryLines.push(`Credentials: ~/.codex/hooks/cozeloop.env`);
5063
+ summaryLines.push(`Hook script: ${written.hookScript || '~/.codex/hooks/cozeloop_hook.py'}`);
5064
+ summaryLines.push(`Config: ${written.hooksJson || '~/.codex/hooks.json'}`);
5065
+ summaryLines.push(`Credentials: ${written.envFile || '~/.codex/hooks/cozeloop.env'}`);
4976
5066
  } else {
4977
5067
  summaryLines.push(`Config: ~/.openclaw/openclaw.json`);
4978
5068
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -431,6 +431,29 @@ def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str,
431
431
  return steps
432
432
 
433
433
 
434
+ def _parse_ts(msg):
435
+ """从 transcript msg 的 timestamp 字段解析出 datetime(带时区)。失败返回 None。
436
+
437
+ Claude Code transcript 每条 user/assistant 消息都带 ISO8601 timestamp
438
+ (如 "2026-06-01T13:51:55.450Z")。建 span 时用它做 start_time,
439
+ 避免回放时所有 span 挤在几毫秒内、duration 全是几毫秒。
440
+ """
441
+ if not isinstance(msg, dict):
442
+ return None
443
+ ts = msg.get("timestamp")
444
+ if not ts or not isinstance(ts, str):
445
+ return None
446
+ try:
447
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
448
+ except Exception:
449
+ return None
450
+
451
+
452
+ def _ts_ms(dt):
453
+ """datetime → 毫秒时间戳(int);None → None。"""
454
+ return int(dt.timestamp() * 1000) if dt is not None else None
455
+
456
+
434
457
  def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
435
458
  """Group messages into conversation turns (user -> assistant -> tool_results).
436
459
 
@@ -793,13 +816,31 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
793
816
  client = cozeloop.new_client(**client_kwargs)
794
817
 
795
818
  try:
796
- with client.start_span(name="claude_code_request", span_type="main") as root_span:
819
+ # 整体时间范围:第一条 user 消息 → 最后一条 assistant 消息(真实 transcript 时间)
820
+ _all_ts = []
821
+ for _t in turns:
822
+ _u = _parse_ts(_t.get("user_message"))
823
+ if _u:
824
+ _all_ts.append(_u)
825
+ for _s in _t.get("steps", []):
826
+ _a = _parse_ts(_s.get("assistant_message"))
827
+ if _a:
828
+ _all_ts.append(_a)
829
+ root_start_dt = min(_all_ts) if _all_ts else None
830
+ root_end_dt = max(_all_ts) if _all_ts else None
831
+
832
+ with client.start_span(name="claude_code_request", span_type="main", start_time=root_start_dt) as root_span:
797
833
  root_span.set_runtime(Runtime(library="claude-code"))
798
834
  root_tags = {
799
835
  "thread_id": session_id,
800
836
  "total_turns": len(turns),
801
837
  "source": "claude_code"
802
838
  }
839
+ # 真实耗时写 tag(SDK finish 不支持显式结束时间,duration 用 tag 兜底)
840
+ if root_start_dt is not None and root_end_dt is not None:
841
+ root_tags["real_start_ms"] = _ts_ms(root_start_dt)
842
+ root_tags["real_end_ms"] = _ts_ms(root_end_dt)
843
+ root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
803
844
  root_baggage = {
804
845
  "thread_id": session_id,
805
846
  }
@@ -836,14 +877,29 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
836
877
  steps = turn.get("steps", [])
837
878
  total_steps = len(steps)
838
879
 
839
- with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
880
+ # turn 真实时间:start=user 消息时间,end=最后一个 step 的 assistant 时间
881
+ turn_start_dt = _parse_ts(turn.get("user_message"))
882
+ turn_end_dt = None
883
+ for _s in steps:
884
+ _a = _parse_ts(_s.get("assistant_message"))
885
+ if _a:
886
+ turn_end_dt = _a
887
+ if turn_start_dt is None:
888
+ turn_start_dt = turn_end_dt
889
+
890
+ with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
840
891
  turn_span.set_runtime(Runtime(library="claude-code"))
841
- turn_span.set_tags({
892
+ _turn_tags = {
842
893
  "thread_id": session_id,
843
894
  "turn_index": i,
844
895
  "total_steps": total_steps,
845
896
  "source": "claude_code",
846
- })
897
+ }
898
+ if turn_start_dt is not None and turn_end_dt is not None:
899
+ _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
900
+ _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
901
+ _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
902
+ turn_span.set_tags(_turn_tags)
847
903
 
848
904
  # Extract user input for this turn
849
905
  user_message = turn.get("user_message", {}).get("message", {})
@@ -861,10 +917,23 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
861
917
  raw_content = assistant_message_obj.get("content", [])
862
918
  model_name = assistant_message_obj.get("model", "claude-code")
863
919
 
920
+ # step 真实时间:start=本 step assistant 时间;end=下一 step assistant 时间(即本次模型调用+工具的耗时),无下一 step 则用 turn_end
921
+ step_start_dt = _parse_ts(assistant_msg)
922
+ if j + 1 < len(steps):
923
+ step_end_dt = _parse_ts(steps[j + 1].get("assistant_message")) or turn_end_dt
924
+ else:
925
+ step_end_dt = turn_end_dt
926
+
864
927
  # --- Create model span for this step ---
865
- with client.start_span(name=f"model_call_{j}", span_type="model") as model_span:
928
+ with client.start_span(name=f"model_call_{j}", span_type="model", start_time=step_start_dt) as model_span:
866
929
  model_span.set_runtime(Runtime(library="claude-code"))
867
930
  model_span.set_model_name(model_name)
931
+ if step_start_dt is not None and step_end_dt is not None:
932
+ model_span.set_tags({
933
+ "real_start_ms": _ts_ms(step_start_dt),
934
+ "real_end_ms": _ts_ms(step_end_dt),
935
+ "latency_ms": _ts_ms(step_end_dt) - _ts_ms(step_start_dt),
936
+ })
868
937
 
869
938
  # Set input: accumulated context up to this point
870
939
  model_span.set_input(ModelInput(
@@ -958,7 +1027,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
958
1027
  span_type = "agent" if is_agent else "tool"
959
1028
  span_name = f"agent_{tool_name}" if is_agent else f"tool_{tool_name}"
960
1029
 
961
- with client.start_span(name=span_name, span_type=span_type) as tool_span:
1030
+ with client.start_span(name=span_name, span_type=span_type, start_time=step_start_dt) as tool_span:
962
1031
  tool_span.set_runtime(Runtime(library="claude-code"))
963
1032
  tags = {
964
1033
  "tool_name": tool_name,
@@ -415,6 +415,28 @@ def truncate_text(text: str, limit: int = 12000) -> str:
415
415
 
416
416
  # --- Message Grouping ---
417
417
 
418
+ def _parse_ts(obj):
419
+ """从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
420
+
421
+ Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
422
+ start_time,避免回放时所有 span 挤在几毫秒内。
423
+ """
424
+ if not isinstance(obj, dict):
425
+ return None
426
+ ts = obj.get("timestamp") or obj.get("_ts")
427
+ if not ts or not isinstance(ts, str):
428
+ return None
429
+ try:
430
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
431
+ except Exception:
432
+ return None
433
+
434
+
435
+ def _ts_ms(dt):
436
+ """datetime → 毫秒时间戳(int);None → None。"""
437
+ return int(dt.timestamp() * 1000) if dt is not None else None
438
+
439
+
418
440
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
419
441
  """Group raw JSONL entries into conversation turns.
420
442
 
@@ -437,6 +459,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
437
459
  for entry in entries:
438
460
  entry_type = entry.get("type")
439
461
  payload = entry.get("payload", {})
462
+ # 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
463
+ if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
464
+ payload["_ts"] = entry.get("timestamp")
440
465
 
441
466
  # --- Turn lifecycle events ---
442
467
  if entry_type == "event_msg":
@@ -645,13 +670,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
645
670
  ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
646
671
 
647
672
  try:
648
- with client.start_span(name="codex_request", span_type="main") as root_span:
673
+ # 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
674
+ _all_ts = []
675
+ for _t in turns:
676
+ _u = _parse_ts(_t.get("user_message"))
677
+ if _u:
678
+ _all_ts.append(_u)
679
+ for _a in _t.get("assistant_messages", []):
680
+ _ad = _parse_ts(_a)
681
+ if _ad:
682
+ _all_ts.append(_ad)
683
+ root_start_dt = min(_all_ts) if _all_ts else None
684
+ root_end_dt = max(_all_ts) if _all_ts else None
685
+
686
+ with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
649
687
  root_span.set_runtime(Runtime(library="codex-cli"))
650
688
  root_tags = {
651
689
  "thread_id": session_id,
652
690
  "total_turns": len(turns),
653
691
  "source": "codex_cli",
654
692
  }
693
+ if root_start_dt is not None and root_end_dt is not None:
694
+ root_tags["real_start_ms"] = _ts_ms(root_start_dt)
695
+ root_tags["real_end_ms"] = _ts_ms(root_end_dt)
696
+ root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
655
697
  root_baggage = {
656
698
  "thread_id": session_id,
657
699
  }
@@ -689,20 +731,49 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
689
731
  # Process each turn
690
732
  for i, turn in enumerate(turns):
691
733
  try:
692
- with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
734
+ # turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
735
+ turn_start_dt = _parse_ts(turn.get("user_message"))
736
+ turn_end_dt = None
737
+ for _a in turn.get("assistant_messages", []):
738
+ _ad = _parse_ts(_a)
739
+ if _ad:
740
+ turn_end_dt = _ad
741
+ if turn_start_dt is None:
742
+ turn_start_dt = turn_end_dt
743
+
744
+ with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
693
745
  turn_span.set_runtime(Runtime(library="codex-cli"))
694
- turn_span.set_tags({
746
+ _turn_tags = {
695
747
  "thread_id": session_id,
696
748
  "turn_index": i,
697
749
  "turn_id": turn.get("turn_id", ""),
698
750
  "source": "codex_cli",
699
- })
751
+ }
752
+ if turn_start_dt is not None and turn_end_dt is not None:
753
+ _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
754
+ _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
755
+ _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
756
+ turn_span.set_tags(_turn_tags)
700
757
 
701
758
  # --- Model span for assistant response ---
702
759
  if turn.get("assistant_messages"):
703
- with client.start_span(name="assistant_response", span_type="model") as model_span:
760
+ # model span start:第一条 assistant payload 时间,回退到 turn 起点
761
+ _model_start_dt = None
762
+ for _a in turn.get("assistant_messages", []):
763
+ _model_start_dt = _parse_ts(_a)
764
+ if _model_start_dt:
765
+ break
766
+ if _model_start_dt is None:
767
+ _model_start_dt = turn_start_dt
768
+ with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
704
769
  model_span.set_runtime(Runtime(library="codex-cli"))
705
770
  model_span.set_model_name(model_name)
771
+ if _model_start_dt is not None and turn_end_dt is not None:
772
+ model_span.set_tags({
773
+ "real_start_ms": _ts_ms(_model_start_dt),
774
+ "real_end_ms": _ts_ms(turn_end_dt),
775
+ "latency_ms": _ts_ms(turn_end_dt) - _ts_ms(_model_start_dt),
776
+ })
706
777
 
707
778
  # Build input messages: history + current turn input
708
779
  turn_input = turn.get("input_messages", [])
@@ -770,7 +841,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
770
841
  # --- Tool call spans ---
771
842
  for tool_call in turn.get("tool_calls", []):
772
843
  tool_name = tool_call.get("name", "unknown")
773
- with client.start_span(name=f"tool_{tool_name}", span_type="tool") as tool_span:
844
+ with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=turn_start_dt) as tool_span:
774
845
  tool_span.set_runtime(Runtime(library="codex-cli"))
775
846
  tool_span.set_tags({
776
847
  "tool_name": tool_name,
@@ -794,7 +865,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
794
865
  agent_id = sc.get("agent_id") or "unknown"
795
866
  nickname = sc.get("nickname") or agent_id
796
867
 
797
- with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
868
+ with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=turn_start_dt) as subagent_span:
798
869
  subagent_span.set_runtime(Runtime(library="codex-cli"))
799
870
  subagent_span.set_tags({
800
871
  "agent_id": agent_id,
@@ -36,7 +36,7 @@ async function _refreshToken(refreshTok) {
36
36
  const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
37
37
  const req = https.request(`${_COZE_API}/api/permission/oauth2/token`, {
38
38
  method: "POST",
39
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
39
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
40
40
  }, (res) => {
41
41
  let buf = "";
42
42
  res.on("data", c => buf += c);
@@ -170,6 +170,8 @@ export class CozeloopExporter {
170
170
  headers: {
171
171
  "Authorization": authorization,
172
172
  "cozeloop-workspace-id": workspaceId,
173
+ "x-tt-env": "ppe_cozelab",
174
+ "x-use-ppe": "1",
173
175
  },
174
176
  });
175
177
  this.provider = new BasicTracerProvider({ resource });