coze_lab 0.1.4 → 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
@@ -4,8 +4,9 @@
4
4
  // ─── 0. Constants ─────────────────────────────────────────────────────────────
5
5
  const CLIENT_ID = '46371084383473718052118955183420.app.coze';
6
6
  const WORKSPACE_ID = '7644910356078837760';
7
- // TEMP(test): 临时写死 token,跳过 OAuth。测试结束后请删除并恢复 getValidToken() 流程。
8
- const HARDCODED_TOKEN = 'sat_KwzIM1cOhtgrKUAbxCbPTjmwxEktM61BVtkekkLD6DZldTv6keCtbzJHOzWaFynu';
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';
9
10
  const COZE_API = 'https://api.coze.cn';
10
11
  const CREDS_PATH = require('path').join(require('os').homedir(), '.cozeloop', 'credentials.json');
11
12
  // Refresh when less than 10 minutes remain
@@ -3457,7 +3458,7 @@ async function _refreshToken(refreshTok) {
3457
3458
  const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
3458
3459
  const req = https.request(\`\${_COZE_API}/api/permission/oauth2/token\`, {
3459
3460
  method: "POST",
3460
- 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" },
3461
3462
  }, (res) => {
3462
3463
  let buf = "";
3463
3464
  res.on("data", c => buf += c);
@@ -3591,6 +3592,8 @@ export class CozeloopExporter {
3591
3592
  headers: {
3592
3593
  "Authorization": authorization,
3593
3594
  "cozeloop-workspace-id": workspaceId,
3595
+ "x-tt-env": "ppe_cozelab",
3596
+ "x-use-ppe": "1",
3594
3597
  },
3595
3598
  });
3596
3599
  this.provider = new BasicTracerProvider({ resource });
@@ -4119,20 +4122,61 @@ function parseArgs() {
4119
4122
 
4120
4123
  const VALID_AGENTS = ['claude-code', 'codex', 'openclaw'];
4121
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
+
4122
4155
  function validateArgs(args) {
4123
4156
  if (args['logout']) return { logout: true };
4124
4157
  if (args['login']) return { login: true };
4125
4158
  if (args['status']) return { status: true };
4126
4159
  if (args['refresh']) return { refresh: true };
4127
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
+
4128
4173
  if (!args['agent']) {
4129
4174
  errorBox([
4130
- 'ERROR: --agent is required',
4175
+ 'ERROR: --agent --agent-id 至少提供一个',
4131
4176
  '',
4132
4177
  'Usage:',
4133
- ' --agent=claude-code',
4134
- ' --agent=codex',
4135
- ' --agent=openclaw',
4178
+ ' --agent=claude-code | codex | openclaw (全局配置)',
4179
+ ' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
4136
4180
  '',
4137
4181
  'Other commands:',
4138
4182
  ' --status Show authorization status',
@@ -4151,7 +4195,7 @@ function validateArgs(args) {
4151
4195
  ' --agent=openclaw',
4152
4196
  ]);
4153
4197
  }
4154
- return { agent: args['agent'] };
4198
+ return { agent: args['agent'], 'codex-home': args['codex-home'] };
4155
4199
  }
4156
4200
 
4157
4201
  // ─── 4. Agent detection ──────────────────────────────────────────────────────
@@ -4339,22 +4383,31 @@ function mergeJson(filepath, mergeFn) {
4339
4383
  return mergeFn(existing);
4340
4384
  }
4341
4385
 
4342
- 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) {
4343
4390
  const hooksDir = path.join(os.homedir(), '.claude', 'hooks');
4344
4391
  const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
4345
- const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
4346
- const localSettingsPath = path.join(process.cwd(), '.claude', 'settings.local.json');
4347
-
4348
- // 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,可共享
4349
4401
  ensureDir(hooksDir);
4350
4402
  writeHookScript(hookScript, readScript('claude-code/cozeloop_hook.py'));
4351
4403
  const refreshScript = path.join(hooksDir, 'cozeloop_refresh.py');
4352
4404
  writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
4353
4405
 
4354
- // 2. Merge global settings.json — Stop (trace) + UserPromptSubmit (refresh)
4355
- const hookCmd = `${pythonCmd} ~/.claude/hooks/cozeloop_hook.py`;
4356
- 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}`;
4357
4409
 
4410
+ ensureDir(claudeDir);
4358
4411
  const settings = mergeJson(settingsPath, (existing) => {
4359
4412
  if (!existing.hooks) existing.hooks = {};
4360
4413
 
@@ -4379,14 +4432,17 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4379
4432
  } catch (e) {
4380
4433
  errorBox([`ERROR: Cannot write ${settingsPath}`, '', e.message]);
4381
4434
  }
4382
- ok(`Global hook registered in ${settingsPath}`);
4435
+ ok(`Hook registered in ${settingsPath}`);
4383
4436
 
4384
- // 3. Write project-level credentials
4385
- ensureDir(path.join(process.cwd(), '.claude'));
4437
+ // 3. Write credentials into <baseDir>/.claude/settings.local.json
4438
+ ensureDir(path.join(baseDir, '.claude'));
4386
4439
  const localSettings = mergeJson(localSettingsPath, (existing) => {
4387
4440
  if (!existing.env) existing.env = {};
4388
4441
  existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
4389
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;
4390
4446
  return existing;
4391
4447
  });
4392
4448
  try {
@@ -4400,11 +4456,14 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4400
4456
  return { hookScript, settingsPath, localSettingsPath };
4401
4457
  }
4402
4458
 
4403
- function writeCodexHook(token, workspaceId, pythonCmd) {
4404
- 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');
4405
4464
  const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
4406
4465
  const envFile = path.join(hooksDir, 'cozeloop.env');
4407
- const hooksJson = path.join(os.homedir(), '.codex', 'hooks.json');
4466
+ const hooksJson = path.join(home, 'hooks.json');
4408
4467
 
4409
4468
  // 1. Write Python hook scripts (trace + refresh)
4410
4469
  // Token is read from ~/.cozeloop/credentials.json at runtime
@@ -4418,6 +4477,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4418
4477
  `COZELOOP_WORKSPACE_ID=${workspaceId}`,
4419
4478
  `COZELOOP_API_TOKEN=${token}`,
4420
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}`,
4421
4483
  ].join('\n') + '\n';
4422
4484
  try {
4423
4485
  fs.writeFileSync(envFile, envContent, { mode: 0o600 });
@@ -4427,8 +4489,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4427
4489
  ok(`Credentials written to ${envFile} (chmod 600)`);
4428
4490
 
4429
4491
  // 3. Merge hooks.json — Stop (trace) + SessionStart (refresh)
4430
- const hookCmd = `set -a && . ~/.codex/hooks/cozeloop.env && set +a && ${pythonCmd} ~/.codex/hooks/cozeloop_hook.py`;
4431
- 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}`;
4432
4495
 
4433
4496
  const hooks = mergeJson(hooksJson, (existing) => {
4434
4497
  if (!existing.hooks) existing.hooks = {};
@@ -4553,6 +4616,17 @@ function writeOpenClawHook(token, workspaceId) {
4553
4616
  errorBox([`ERROR: Cannot write ${configPath}`, '', e.message]);
4554
4617
  }
4555
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
+
4556
4630
  return { configPath, pluginDir };
4557
4631
  }
4558
4632
 
@@ -4565,7 +4639,8 @@ function httpsPost(url, body) {
4565
4639
  const u = new URL(url);
4566
4640
  const req = https.request(
4567
4641
  { hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
4568
- 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 } },
4569
4644
  (res) => {
4570
4645
  let buf = '';
4571
4646
  res.on('data', c => buf += c);
@@ -4581,7 +4656,9 @@ function httpsPost(url, body) {
4581
4656
 
4582
4657
  function httpsGet(url, headers) {
4583
4658
  return new Promise((resolve, reject) => {
4584
- 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) => {
4585
4662
  let buf = '';
4586
4663
  res.on('data', c => buf += c);
4587
4664
  res.on('end', () => resolve({ status: res.statusCode, body: buf }));
@@ -4847,9 +4924,9 @@ async function verifyWorkspace(token) {
4847
4924
 
4848
4925
  // ─── 9. Main ─────────────────────────────────────────────────────────────────
4849
4926
  const NEXT_STEP = {
4850
- 'claude-code': 'Start a new Claude Code session to begin tracing.',
4851
- 'codex': 'Start a new Codex session to begin tracing.',
4852
- '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 即刻生效。',
4853
4930
  };
4854
4931
 
4855
4932
  async function main() {
@@ -4915,14 +4992,31 @@ async function main() {
4915
4992
  }
4916
4993
 
4917
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
+ }
4918
5001
 
4919
- // Step 1: Authorize — TEMP(test): 跳过 OAuth,直接使用写死 token
4920
- // Step 2: Verify token works — TEMP(test): 一并跳过
4921
- info('Step 1/5: 使用写死 token(已跳过 OAuth)...');
4922
- let token = HARDCODED_TOKEN;
5002
+ // Step 1: Authorize (load cached refresh → device code)
5003
+ info('Step 1/5: 检查授权状态...');
5004
+ let token = await getValidToken();
4923
5005
  console.log('');
4924
5006
 
4925
- info('Step 2/5: 跳过工作空间权限验证(测试模式)...');
5007
+ // Step 2: Verify token works
5008
+ info('Step 2/5: 验证工作空间权限...');
5009
+ let verified = await verifyWorkspace(token);
5010
+ if (verified === null) {
5011
+ warn('Token 已失效,正在重新授权...');
5012
+ deleteCredentials();
5013
+ console.log('');
5014
+ token = await deviceCodeAuth().then(c => c.access_token);
5015
+ verified = await verifyWorkspace(token);
5016
+ if (verified === null) {
5017
+ errorBox(['ERROR: 重新授权后 Token 仍无效,请联系管理员。']);
5018
+ }
5019
+ }
4926
5020
  console.log('');
4927
5021
 
4928
5022
  // Step 3: Detect agent binary
@@ -4942,27 +5036,33 @@ async function main() {
4942
5036
 
4943
5037
  // Step 5: Write hook configuration
4944
5038
  info('Step 5/5: Writing hook configuration...');
5039
+ let written = {};
4945
5040
  if (agent === 'claude-code') {
4946
- writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd);
5041
+ // per-agent 路由时把配置写进 agent 的 workspace(仅该 agent 生效)。
5042
+ written = writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd, args.agentId ? agentWorkspace : undefined);
4947
5043
  } else if (agent === 'codex') {
4948
- 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);
4949
5048
  } else {
4950
- writeOpenClawHook(token, WORKSPACE_ID);
5049
+ written = writeOpenClawHook(token, WORKSPACE_ID) || {};
4951
5050
  }
4952
5051
  console.log('');
4953
5052
 
4954
- // Success summary
5053
+ // Success summary(用实际写入路径,per-agent / 自定义 CODEX_HOME 时也准确)
4955
5054
  const summaryLines = [
4956
5055
  `Agent: ${agent} v${version}`,
4957
5056
  ];
5057
+ if (args.agentId) summaryLines.push(`Agent ID: ${args.agentId}`);
4958
5058
  if (agent === 'claude-code') {
4959
- summaryLines.push(`Hook script: ~/.claude/hooks/cozeloop_hook.py`);
4960
- summaryLines.push(`Config: ~/.claude/settings.json`);
4961
- 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'}`);
4962
5062
  } else if (agent === 'codex') {
4963
- summaryLines.push(`Hook script: ~/.codex/hooks/cozeloop_hook.py`);
4964
- summaryLines.push(`Config: ~/.codex/hooks.json`);
4965
- 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'}`);
4966
5066
  } else {
4967
5067
  summaryLines.push(`Config: ~/.openclaw/openclaw.json`);
4968
5068
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.4",
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",
@@ -45,7 +45,7 @@ DEBUG = os.environ.get("CC_COZELOOP_DEBUG", "").lower() == "true"
45
45
  _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
46
46
  _COZE_API = "https://api.coze.cn"
47
47
  _REFRESH_THRESHOLD = 10 * 60 # refresh when < 10 minutes remain
48
- _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
48
+ _DEFAULT_WORKSPACE_ID = "7645949103524380682" # hardcoded spaceID fallback
49
49
 
50
50
 
51
51
  # --- coze-context parsing -------------------------------------------------
@@ -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,
@@ -45,7 +45,7 @@ from typing import Optional, List, Dict, Any
45
45
  _COZELOOP_CLIENT_ID = "46371084383473718052118955183420.app.coze"
46
46
  _COZE_API = "https://api.coze.cn"
47
47
  _REFRESH_THRESHOLD = 10 * 60
48
- _DEFAULT_WORKSPACE_ID = "7644910356078837760" # hardcoded spaceID fallback
48
+ _DEFAULT_WORKSPACE_ID = "7645949103524380682" # hardcoded spaceID fallback
49
49
 
50
50
 
51
51
  # --- coze-context parsing -------------------------------------------------
@@ -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 });