coze_lab 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @cozeloop/onboard
1
+ # coze_lab
2
2
 
3
3
  Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop.
4
4
 
@@ -6,13 +6,13 @@ Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to Coz
6
6
 
7
7
  ```bash
8
8
  # First-time setup — triggers browser OAuth authorization
9
- npx @cozeloop/onboard --agent=<type>
9
+ npx coze_lab --agent=<type>
10
10
 
11
11
  # Auth-only commands (no agent configuration)
12
- npx @cozeloop/onboard --login # Device Code login only
13
- npx @cozeloop/onboard --status # Show current authorization status
14
- npx @cozeloop/onboard --refresh # Force-refresh the access token
15
- npx @cozeloop/onboard --logout # Clear cached credentials
12
+ npx coze_lab --login # Device Code login only
13
+ npx coze_lab --status # Show current authorization status
14
+ npx coze_lab --refresh # Force-refresh the access token
15
+ npx coze_lab --logout # Clear cached credentials
16
16
  ```
17
17
 
18
18
  ### Parameters
@@ -62,7 +62,7 @@ OAuth tokens are stored in `~/.cozeloop/credentials.json` (mode 600).
62
62
 
63
63
  This means traces continue to upload without interruption even across token expiry, as long as the refresh token is still valid.
64
64
 
65
- **For OpenClaw**, the token is read from `openclaw.json` at startup. Re-run `npx @cozeloop/onboard --agent=openclaw` to refresh it if OpenClaw reports auth errors.
65
+ **For OpenClaw**, the token is read from `openclaw.json` at startup. Re-run `npx coze_lab --agent=openclaw` to refresh it if OpenClaw reports auth errors.
66
66
 
67
67
  ## Supported versions
68
68
 
package/index.js CHANGED
@@ -4,13 +4,41 @@
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
12
13
  const REFRESH_THRESHOLD_MS = 10 * 60 * 1000;
13
14
 
15
+ // ─── 1. Cloud structured output ──────────────────────────────────────────────
16
+ // 云端(--cloud)模式:在 stdout 输出一行机器可读结果 COZE_LAB_RESULT={...},
17
+ // 供管理后台解析判定(inject/verify/logid/message),不依赖中文文案。
18
+ let CLOUD_MODE = false;
19
+ const cloudResult = { inject: 'skip', verify: 'skip', logid: '', message: '' };
20
+
21
+ // errorBox 在云端模式下抛此异常(而非 process.exit),由 main 外层统一收尾。
22
+ class CloudAbort extends Error {}
23
+
24
+ // 从 onboard 失败输出 / verify response body 中提取 server logid。
25
+ // 兼容两种形态:JSON 里的 "logid":"xxx" / detail.logid,以及裸 logid=xxx。
26
+ function extractLogid(text) {
27
+ if (!text) return '';
28
+ let m = text.match(/"logid"\s*:\s*"([a-zA-Z0-9]+)"/);
29
+ if (m) return m[1];
30
+ m = text.match(/logid[=:\s]+([a-zA-Z0-9]+)/i);
31
+ return m ? m[1] : '';
32
+ }
33
+
34
+ // 输出结构化结果行(仅云端模式)。
35
+ function emitCloudResult() {
36
+ if (!CLOUD_MODE) return;
37
+ // message 压成单行,避免破坏 COZE_LAB_RESULT 单行约定。
38
+ const safe = { ...cloudResult, message: String(cloudResult.message || '').replace(/\s+/g, ' ').slice(0, 500) };
39
+ console.log('COZE_LAB_RESULT=' + JSON.stringify(safe));
40
+ }
41
+
14
42
  // ─── 1. Color helpers ────────────────────────────────────────────────────────
15
43
  const C = {
16
44
  reset: '\x1b[0m',
@@ -40,6 +68,10 @@ function box(lines, color) {
40
68
 
41
69
  function errorBox(lines) {
42
70
  box(lines, C.red);
71
+ // 云端模式下不直接退出,改抛异常,让 main 外层统一输出 COZE_LAB_RESULT 结构化结果。
72
+ if (CLOUD_MODE) {
73
+ throw new CloudAbort(lines.join(' '));
74
+ }
43
75
  process.exit(1);
44
76
  }
45
77
 
@@ -3457,7 +3489,7 @@ async function _refreshToken(refreshTok) {
3457
3489
  const body = JSON.stringify({ grant_type: "refresh_token", client_id: _CLIENT_ID, refresh_token: refreshTok });
3458
3490
  const req = https.request(\`\${_COZE_API}/api/permission/oauth2/token\`, {
3459
3491
  method: "POST",
3460
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
3492
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "x-tt-env": "ppe_cozelab", "x-use-ppe": "1" },
3461
3493
  }, (res) => {
3462
3494
  let buf = "";
3463
3495
  res.on("data", c => buf += c);
@@ -3591,6 +3623,8 @@ export class CozeloopExporter {
3591
3623
  headers: {
3592
3624
  "Authorization": authorization,
3593
3625
  "cozeloop-workspace-id": workspaceId,
3626
+ "x-tt-env": "ppe_cozelab",
3627
+ "x-use-ppe": "1",
3594
3628
  },
3595
3629
  });
3596
3630
  this.provider = new BasicTracerProvider({ resource });
@@ -4111,6 +4145,8 @@ function parseArgs() {
4111
4145
  if (arg === '--login') { args['login'] = true; continue; }
4112
4146
  if (arg === '--status') { args['status'] = true; continue; }
4113
4147
  if (arg === '--refresh') { args['refresh'] = true; continue; }
4148
+ if (arg === '--verify') { args['verify'] = true; continue; }
4149
+ if (arg === '--cloud') { args['cloud'] = true; continue; }
4114
4150
  const m = arg.match(/^--([^=]+)=(.+)$/);
4115
4151
  if (m) args[m[1]] = m[2];
4116
4152
  }
@@ -4119,26 +4155,72 @@ function parseArgs() {
4119
4155
 
4120
4156
  const VALID_AGENTS = ['claude-code', 'codex', 'openclaw'];
4121
4157
 
4158
+ // resolveAgent 读 ~/.coze/agents/<agentId>/config.json,返回 { framework, workspace, agentId, root }。
4159
+ // 目录或 config 不存在、framework 非法时直接报错退出。
4160
+ function resolveAgent(agentId) {
4161
+ const root = path.join(os.homedir(), '.coze', 'agents', agentId);
4162
+ const configPath = path.join(root, 'config.json');
4163
+ if (!fs.existsSync(configPath)) {
4164
+ errorBox([
4165
+ `ERROR: agent "${agentId}" 不存在`,
4166
+ '',
4167
+ `未找到 ${configPath}`,
4168
+ '请确认 agentId 正确,或先用 coze-bridge 创建该 agent。',
4169
+ ]);
4170
+ }
4171
+ let cfg;
4172
+ try {
4173
+ cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
4174
+ } catch (e) {
4175
+ errorBox([`ERROR: 解析 ${configPath} 失败`, '', e.message]);
4176
+ }
4177
+ const framework = cfg.framework;
4178
+ if (!VALID_AGENTS.includes(framework)) {
4179
+ errorBox([
4180
+ `ERROR: agent "${agentId}" 的 framework="${framework}" 不受支持`,
4181
+ '',
4182
+ `支持的类型: ${VALID_AGENTS.join(', ')}`,
4183
+ ]);
4184
+ }
4185
+ return { framework, workspace: cfg.workspace || '', agentId, root };
4186
+ }
4187
+
4122
4188
  function validateArgs(args) {
4123
4189
  if (args['logout']) return { logout: true };
4124
4190
  if (args['login']) return { login: true };
4125
4191
  if (args['status']) return { status: true };
4126
4192
  if (args['refresh']) return { refresh: true };
4193
+ if (args['verify']) return { verify: true, pairCode: args['pair-code'] };
4194
+
4195
+ // 优先 --agent-id:读 ~/.coze/agents/<id>/config.json 的 framework 自动路由。
4196
+ if (args['agent-id']) {
4197
+ const resolved = resolveAgent(args['agent-id']);
4198
+ return {
4199
+ agent: resolved.framework,
4200
+ agentId: resolved.agentId,
4201
+ workspace: resolved.workspace,
4202
+ agentRoot: resolved.root,
4203
+ 'codex-home': args['codex-home'],
4204
+ pairCode: args['pair-code'],
4205
+ cloud: !!args['cloud'],
4206
+ };
4207
+ }
4127
4208
 
4128
4209
  if (!args['agent']) {
4129
4210
  errorBox([
4130
- 'ERROR: --agent is required',
4211
+ 'ERROR: --agent --agent-id 至少提供一个',
4131
4212
  '',
4132
4213
  'Usage:',
4133
- ' --agent=claude-code',
4134
- ' --agent=codex',
4135
- ' --agent=openclaw',
4214
+ ' --agent=claude-code | codex | openclaw (全局配置)',
4215
+ ' --agent-id=<id> (按 ~/.coze/agents/<id> 的 framework 自动路由)',
4136
4216
  '',
4137
4217
  'Other commands:',
4138
4218
  ' --status Show authorization status',
4139
4219
  ' --login Login (Device Code flow)',
4140
4220
  ' --refresh Force refresh access token',
4141
4221
  ' --logout Clear cached credentials',
4222
+ ' --verify Send a test trace to verify the reporting pipeline',
4223
+ ' 可带 --pair-code=<值> 写入 trace metadata(缺省自动生成),供查询方回查',
4142
4224
  ]);
4143
4225
  }
4144
4226
  if (!VALID_AGENTS.includes(args['agent'])) {
@@ -4151,7 +4233,7 @@ function validateArgs(args) {
4151
4233
  ' --agent=openclaw',
4152
4234
  ]);
4153
4235
  }
4154
- return { agent: args['agent'] };
4236
+ return { agent: args['agent'], 'codex-home': args['codex-home'], pairCode: args['pair-code'], cloud: !!args['cloud'] };
4155
4237
  }
4156
4238
 
4157
4239
  // ─── 4. Agent detection ──────────────────────────────────────────────────────
@@ -4297,7 +4379,7 @@ function checkVersionWhitelist(agent, version) {
4297
4379
  'Trace reporting may not work correctly.',
4298
4380
  'Please upgrade and re-run:',
4299
4381
  ` ${UPGRADE_CMD[agent]}`,
4300
- ` npx @cozeloop/onboard --agent=${agent} ...`,
4382
+ ` npx coze_lab --agent=${agent} ...`,
4301
4383
  ]);
4302
4384
  }
4303
4385
 
@@ -4339,22 +4421,33 @@ function mergeJson(filepath, mergeFn) {
4339
4421
  return mergeFn(existing);
4340
4422
  }
4341
4423
 
4342
- function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4424
+ // writeClaudeCodeHook 配置 Claude Code 的 hook。
4425
+ // configBaseDir 缺省 process.cwd()(全局/项目级);传入 agent 的 workspace 则 per-agent:
4426
+ // settings.json + 凭证写进 <configBaseDir>/.claude,仅该 agent(以此为 cwd 启动)生效。
4427
+ // cloud=true 时不把明文 token 写进 settings.local.json —— 云端 hook 运行时直接读
4428
+ // 环境变量 COZE_API_TOKEN(见 scripts/claude-code/cozeloop_hook.py 的 get_fresh_token)。
4429
+ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd, configBaseDir, cloud) {
4343
4430
  const hooksDir = path.join(os.homedir(), '.claude', 'hooks');
4344
4431
  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)
4432
+ const baseDir = configBaseDir || process.cwd();
4433
+ // per-agent settings.json 写进 agent 的 .claude;全局时仍写 ~/.claude/settings.json
4434
+ const claudeDir = configBaseDir
4435
+ ? path.join(baseDir, '.claude')
4436
+ : path.join(os.homedir(), '.claude');
4437
+ const settingsPath = path.join(claudeDir, 'settings.json');
4438
+ const localSettingsPath = path.join(baseDir, '.claude', 'settings.local.json');
4439
+
4440
+ // 1. Write Python hook scripts (trace + refresh) — 脚本放全局 ~/.claude/hooks,可共享
4349
4441
  ensureDir(hooksDir);
4350
4442
  writeHookScript(hookScript, readScript('claude-code/cozeloop_hook.py'));
4351
4443
  const refreshScript = path.join(hooksDir, 'cozeloop_refresh.py');
4352
4444
  writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
4353
4445
 
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`;
4446
+ // 2. Merge settings.json — Stop (trace) + UserPromptSubmit (refresh)。用绝对路径。
4447
+ const hookCmd = `${pythonCmd} ${hookScript}`;
4448
+ const refreshCmd = `${pythonCmd} ${refreshScript}`;
4357
4449
 
4450
+ ensureDir(claudeDir);
4358
4451
  const settings = mergeJson(settingsPath, (existing) => {
4359
4452
  if (!existing.hooks) existing.hooks = {};
4360
4453
 
@@ -4379,14 +4472,23 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4379
4472
  } catch (e) {
4380
4473
  errorBox([`ERROR: Cannot write ${settingsPath}`, '', e.message]);
4381
4474
  }
4382
- ok(`Global hook registered in ${settingsPath}`);
4475
+ ok(`Hook registered in ${settingsPath}`);
4383
4476
 
4384
- // 3. Write project-level credentials
4385
- ensureDir(path.join(process.cwd(), '.claude'));
4477
+ // 3. Write credentials into <baseDir>/.claude/settings.local.json
4478
+ ensureDir(path.join(baseDir, '.claude'));
4386
4479
  const localSettings = mergeJson(localSettingsPath, (existing) => {
4387
4480
  if (!existing.env) existing.env = {};
4388
4481
  existing.env.COZELOOP_WORKSPACE_ID = workspaceId;
4389
- existing.env.COZELOOP_API_TOKEN = patToken;
4482
+ // 云端:token 由 sandbox 注入到环境变量 COZE_API_TOKEN,hook 运行时直接读,
4483
+ // 不在配置文件落明文 token。本地:写入 OAuth 拿到的 token。
4484
+ if (cloud) {
4485
+ delete existing.env.COZELOOP_API_TOKEN;
4486
+ } else {
4487
+ existing.env.COZELOOP_API_TOKEN = patToken;
4488
+ }
4489
+ // PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
4490
+ existing.env.x_tt_env = PPE_TT_ENV;
4491
+ existing.env.x_use_ppe = PPE_USE_PPE;
4390
4492
  return existing;
4391
4493
  });
4392
4494
  try {
@@ -4400,11 +4502,16 @@ function writeClaudeCodeHook(patToken, workspaceId, pythonCmd) {
4400
4502
  return { hookScript, settingsPath, localSettingsPath };
4401
4503
  }
4402
4504
 
4403
- function writeCodexHook(token, workspaceId, pythonCmd) {
4404
- const hooksDir = path.join(os.homedir(), '.codex', 'hooks');
4505
+ // writeCodexHook hook 写进指定的 CODEX_HOME。codexHome 缺省 ~/.codex;
4506
+ // 传入动态目录(如 coze-bridge 的 /tmp/coze-bridge-codex-home-xxx)即可 per-agent 生效。
4507
+ // cloud=true 时 cozeloop.env 不写死 token —— 云端 hook 运行时直接读环境变量
4508
+ // COZE_API_TOKEN(见 scripts/codex/cozeloop_hook.py 的 get_fresh_token)。
4509
+ function writeCodexHook(token, workspaceId, pythonCmd, codexHome, cloud) {
4510
+ const home = codexHome || path.join(os.homedir(), '.codex');
4511
+ const hooksDir = path.join(home, 'hooks');
4405
4512
  const hookScript = path.join(hooksDir, 'cozeloop_hook.py');
4406
4513
  const envFile = path.join(hooksDir, 'cozeloop.env');
4407
- const hooksJson = path.join(os.homedir(), '.codex', 'hooks.json');
4514
+ const hooksJson = path.join(home, 'hooks.json');
4408
4515
 
4409
4516
  // 1. Write Python hook scripts (trace + refresh)
4410
4517
  // Token is read from ~/.cozeloop/credentials.json at runtime
@@ -4414,11 +4521,18 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4414
4521
  writeHookScript(refreshScript, readScript('shared/cozeloop_refresh.py'));
4415
4522
 
4416
4523
  // 2. Write env file with chmod 600
4417
- const envContent = [
4524
+ // 云端(cloud):不落明文 token,hook 运行时从环境变量 COZE_API_TOKEN 读取。
4525
+ const envLines = [
4418
4526
  `COZELOOP_WORKSPACE_ID=${workspaceId}`,
4419
- `COZELOOP_API_TOKEN=${token}`,
4420
- 'TRACE_TO_COZELOOP=true',
4421
- ].join('\n') + '\n';
4527
+ ];
4528
+ if (!cloud) {
4529
+ envLines.push(`COZELOOP_API_TOKEN=${token}`);
4530
+ }
4531
+ envLines.push('TRACE_TO_COZELOOP=true');
4532
+ // PPE 泳道:cozeloop SDK 读这两个环境变量,自动注入 x-tt-env / x-use-ppe header
4533
+ envLines.push(`x_tt_env=${PPE_TT_ENV}`);
4534
+ envLines.push(`x_use_ppe=${PPE_USE_PPE}`);
4535
+ const envContent = envLines.join('\n') + '\n';
4422
4536
  try {
4423
4537
  fs.writeFileSync(envFile, envContent, { mode: 0o600 });
4424
4538
  } catch (e) {
@@ -4427,8 +4541,9 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4427
4541
  ok(`Credentials written to ${envFile} (chmod 600)`);
4428
4542
 
4429
4543
  // 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`;
4544
+ // 命令用绝对路径(CODEX_HOME 不一定是 ~/.codex)。
4545
+ const hookCmd = `set -a && . ${envFile} && set +a && ${pythonCmd} ${hookScript}`;
4546
+ const refreshCmd = `${pythonCmd} ${refreshScript}`;
4432
4547
 
4433
4548
  const hooks = mergeJson(hooksJson, (existing) => {
4434
4549
  if (!existing.hooks) existing.hooks = {};
@@ -4455,12 +4570,16 @@ function writeCodexHook(token, workspaceId, pythonCmd) {
4455
4570
  errorBox([`ERROR: Cannot write ${hooksJson}`, '', e.message]);
4456
4571
  }
4457
4572
  ok(`Hook registered in ${hooksJson}`);
4458
- warn('Codex hook trust: 首次运行 Codex 时会提示信任 hook,请选择"信任"以启用 trace 上报。');
4573
+ warn('Codex hook trust: 首次启动 Codex 会提示 "Hooks need review"。在该提示按 t(Trust all and continue),或在会话内运行 /hooks 后按 t,即可一次性信任全部 hook 启用 trace 上报。');
4459
4574
 
4460
4575
  return { hookScript, envFile, hooksJson };
4461
4576
  }
4462
4577
 
4463
- function writeOpenClawHook(token, workspaceId) {
4578
+ // writeOpenClawHook 配置 OpenClaw 的 cozeloop-trace 插件(全局装在 ~/.openclaw)。
4579
+ // agentId 非空时并入 plugins.entries[...].config.traceAgentIds allowlist —— 插件运行时
4580
+ // 用 resolveAgentIdFromHookCtx 取当前 agentId,仅 allowlist 内的 agent 才上报 trace。
4581
+ // allowlist 为空(本地全局模式)= 全部放行,向后兼容。
4582
+ function writeOpenClawHook(token, workspaceId, agentId) {
4464
4583
  const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
4465
4584
  const pluginDir = path.join(os.homedir(), '.cozeloop', 'openclaw-plugin');
4466
4585
 
@@ -4538,12 +4657,25 @@ function writeOpenClawHook(token, workspaceId) {
4538
4657
  existing.plugins.entries[PLUGIN].enabled = true;
4539
4658
  // hooks.allowConversationAccess required for 2026.5+ to access session content
4540
4659
  existing.plugins.entries[PLUGIN].hooks = { allowConversationAccess: true };
4541
- existing.plugins.entries[PLUGIN].config = {
4542
- authorization: `Bearer ${token}`,
4543
- endpoint: 'https://api.coze.cn/v1/loop/opentelemetry',
4544
- workspaceId,
4545
- debug: true,
4546
- };
4660
+ if (!existing.plugins.entries[PLUGIN].config) existing.plugins.entries[PLUGIN].config = {};
4661
+ const pcfg = existing.plugins.entries[PLUGIN].config;
4662
+ pcfg.authorization = `Bearer ${token}`;
4663
+ pcfg.endpoint = 'https://api.coze.cn/v1/loop/opentelemetry';
4664
+ pcfg.workspaceId = workspaceId;
4665
+ pcfg.debug = true;
4666
+ // per-agent trace 放行:把当前 agentId 并入 traceAgentIds(去重、归一为小写,
4667
+ // 与插件侧 resolveAgentIdFromHookCtx 的归一一致)。无 agentId(全局模式)则不动
4668
+ // allowlist —— 空 allowlist 表示全部放行。
4669
+ if (agentId) {
4670
+ const norm = String(agentId).trim().toLowerCase();
4671
+ // 读 existing 时一并归一(小写、去空),与插件侧 resolveAgentIdFromHookCtx 一致,
4672
+ // 防手工编辑混入大写条目导致去重失效。
4673
+ const list = (Array.isArray(pcfg.traceAgentIds) ? pcfg.traceAgentIds : [])
4674
+ .map((s) => String(s).trim().toLowerCase())
4675
+ .filter(Boolean);
4676
+ if (norm && !list.includes(norm)) list.push(norm);
4677
+ pcfg.traceAgentIds = list;
4678
+ }
4547
4679
  return existing;
4548
4680
  });
4549
4681
 
@@ -4553,19 +4685,33 @@ function writeOpenClawHook(token, workspaceId) {
4553
4685
  errorBox([`ERROR: Cannot write ${configPath}`, '', e.message]);
4554
4686
  }
4555
4687
  ok(`OpenClaw plugin configured in ${configPath}`);
4688
+
4689
+ // 自动重启 gateway,让新插件配置立即生效(不依赖 plugins install 的隐式重启)。
4690
+ try {
4691
+ const { execSync } = require('child_process');
4692
+ info('Restarting OpenClaw gateway to apply hook changes...');
4693
+ execSync('openclaw gateway restart', { stdio: 'pipe' });
4694
+ ok('OpenClaw gateway restarted');
4695
+ } catch (e) {
4696
+ warn(`gateway restart 失败,请手动执行: openclaw gateway restart(${e.message})`);
4697
+ }
4698
+
4556
4699
  return { configPath, pluginDir };
4557
4700
  }
4558
4701
 
4559
4702
  // ─── 8. Auth — Device Code OAuth + token store ───────────────────────────────
4560
4703
  const https = require('https');
4704
+ const crypto = require('crypto');
4561
4705
 
4562
- function httpsPost(url, body) {
4706
+ function httpsPost(url, body, extraHeaders) {
4563
4707
  return new Promise((resolve, reject) => {
4564
4708
  const data = JSON.stringify(body);
4565
4709
  const u = new URL(url);
4566
4710
  const req = https.request(
4567
4711
  { hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
4568
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
4712
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data),
4713
+ 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE,
4714
+ ...(extraHeaders || {}) } },
4569
4715
  (res) => {
4570
4716
  let buf = '';
4571
4717
  res.on('data', c => buf += c);
@@ -4579,9 +4725,71 @@ function httpsPost(url, body) {
4579
4725
  });
4580
4726
  }
4581
4727
 
4728
+ // 真实发一条最小 OTLP trace 到 CozeLoop,验证上报链路是否打通。
4729
+ // 只看 HTTP 状态码(2xx=通),不回查 trace 是否落库——回查由外部查询方完成。
4730
+ // pairCode 写进 span 的 pair_code attribute,供查询方按该字段过滤回查;缺省自动生成。
4731
+ // 不在函数内退出,退出行为交给调用方(主流程 Step 6 / 独立命令 --verify)。
4732
+ async function verifyTraceReport(token, workspaceId, pairCode) {
4733
+ const traceId = crypto.randomBytes(16).toString('hex'); // 32 hex chars
4734
+ const spanId = crypto.randomBytes(8).toString('hex'); // 16 hex chars
4735
+ const nowNs = String(Date.now() * 1_000_000); // OTLP 要求纳秒 unix 时间(字符串)
4736
+ // 缺省自动生成 12 位 hex 配对码;查询方按 field_name="pair_code" 回查。
4737
+ const pair = pairCode || crypto.randomBytes(6).toString('hex');
4738
+
4739
+ const otlpBody = {
4740
+ resourceSpans: [{
4741
+ resource: {
4742
+ attributes: [
4743
+ { key: 'service.name', value: { stringValue: 'cozelab-onboard' } },
4744
+ ],
4745
+ },
4746
+ scopeSpans: [{
4747
+ scope: { name: 'cozelab-onboard-selfcheck' },
4748
+ spans: [{
4749
+ traceId,
4750
+ spanId,
4751
+ name: 'cozelab-onboard-selfcheck',
4752
+ kind: 1,
4753
+ startTimeUnixNano: nowNs,
4754
+ endTimeUnixNano: nowNs,
4755
+ status: { code: 1 },
4756
+ attributes: [
4757
+ { key: 'pair_code', value: { stringValue: pair } },
4758
+ ],
4759
+ }],
4760
+ }],
4761
+ }],
4762
+ };
4763
+
4764
+ let res;
4765
+ try {
4766
+ res = await httpsPost(
4767
+ `${COZE_API}/v1/loop/opentelemetry/v1/traces`,
4768
+ otlpBody,
4769
+ { Authorization: `Bearer ${token}`, 'cozeloop-workspace-id': workspaceId },
4770
+ );
4771
+ } catch (e) {
4772
+ warn(`trace 上报失败: ${e.message}`);
4773
+ return { success: false, status: 0, body: e.message, traceId, pairCode: pair };
4774
+ }
4775
+
4776
+ const success = res.status >= 200 && res.status < 300;
4777
+ if (success) {
4778
+ ok(`trace 上报成功 (traceId=${traceId}, pair_code=${pair})`);
4779
+ info(`查询方可用 pair_code=${pair} 在 CozeLoop 回查确认该 trace 已落库。`);
4780
+ } else {
4781
+ warn(`trace 上报失败: HTTP ${res.status}`);
4782
+ const snippet = (res.body || '').slice(0, 300);
4783
+ if (snippet) console.log(snippet);
4784
+ }
4785
+ return { success, status: res.status, body: res.body, traceId, pairCode: pair };
4786
+ }
4787
+
4582
4788
  function httpsGet(url, headers) {
4583
4789
  return new Promise((resolve, reject) => {
4584
- const req = https.get(url, { headers }, (res) => {
4790
+ // 合并 PPE 泳道 header
4791
+ const h = { ...(headers || {}), 'x-tt-env': PPE_TT_ENV, 'x-use-ppe': PPE_USE_PPE };
4792
+ const req = https.get(url, { headers: h }, (res) => {
4585
4793
  let buf = '';
4586
4794
  res.on('data', c => buf += c);
4587
4795
  res.on('end', () => resolve({ status: res.statusCode, body: buf }));
@@ -4834,22 +5042,11 @@ function authStatus() {
4834
5042
  box(lines, statusColor);
4835
5043
  }
4836
5044
 
4837
- // ── Verify token is usable ────────────────────────────────────────────────
4838
- async function verifyWorkspace(token) {
4839
- info('验证 Token 有效性...');
4840
- // Token validity is guaranteed by the Device Code OAuth flow.
4841
- // We skip a separate API call here since the OAuth app may not have
4842
- // Account.listWorkspace permission — the token itself is the proof.
4843
- if (!token) return null;
4844
- ok(`Token 有效,工作空间 ${WORKSPACE_ID} 确认。`);
4845
- return token;
4846
- }
4847
-
4848
5045
  // ─── 9. Main ─────────────────────────────────────────────────────────────────
4849
5046
  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.',
5047
+ 'claude-code': 'Hook 已写入。Claude Code 会自动热重载 hooks,当前会话即刻生效,无需 /new 或重启。',
5048
+ 'codex': 'Hook 已写入。Codex 在会话启动时加载 hook,当前会话不会即时生效;请重开 Codex 会话(已配置 SessionStart hook,新会话自动加载)。',
5049
+ 'openclaw': 'OpenClaw gateway 已自动重启,trace 即刻生效。',
4853
5050
  };
4854
5051
 
4855
5052
  async function main() {
@@ -4914,24 +5111,53 @@ async function main() {
4914
5111
  return;
4915
5112
  }
4916
5113
 
4917
- const { agent } = args;
5114
+ if (args.verify) {
5115
+ info('验证 trace 上报链路...');
5116
+ const token = await getValidToken(); // 无凭证会自动走登录/刷新
5117
+ console.log('');
5118
+ const result = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode);
5119
+ process.exit(result.success ? 0 : 1);
5120
+ }
4918
5121
 
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;
4923
- console.log('');
5122
+ const { agent } = args;
5123
+ // 云端模式:开启结构化输出 + errorBox 抛异常(而非 exit)。
5124
+ CLOUD_MODE = !!args.cloud;
5125
+ // per-agent 路由时 agent 的 workspace(claude-code 用它做配置根目录)。
5126
+ const agentWorkspace = args.workspace || '';
5127
+ if (args.agentId) {
5128
+ info(`目标 agent: ${args.agentId} (framework=${agent}, workspace=${agentWorkspace || 'N/A'})`);
5129
+ console.log('');
5130
+ }
4924
5131
 
4925
- info('Step 2/5: 跳过工作空间权限验证(测试模式)...');
5132
+ // Step 1: Authorize.
5133
+ // 云端(--cloud):token 取自环境变量 COZE_API_TOKEN,跳过 OAuth / credentials.json。
5134
+ // 本地:load cached → refresh → device code。
5135
+ let token;
5136
+ if (args.cloud) {
5137
+ info('Step 1/5: 云端模式,从环境变量 COZE_API_TOKEN 读取 token...');
5138
+ token = process.env.COZE_API_TOKEN;
5139
+ if (!token) {
5140
+ errorBox([
5141
+ 'ERROR: --cloud 模式要求环境变量 COZE_API_TOKEN',
5142
+ '',
5143
+ '云端 sandbox 应在进程环境中注入 COZE_API_TOKEN。',
5144
+ '未检测到该变量,无法配置 trace 上报。',
5145
+ ]);
5146
+ }
5147
+ ok('已从环境变量读取 COZE_API_TOKEN');
5148
+ } else {
5149
+ info('Step 1/5: 检查授权状态...');
5150
+ token = await getValidToken();
5151
+ }
4926
5152
  console.log('');
4927
5153
 
4928
- // Step 3: Detect agent binary
4929
- info('Step 3/5: Detecting agent...');
5154
+ // Step 2: Detect agent binary
5155
+ info('Step 2/5: Detecting agent...');
4930
5156
  const version = detectAgent(agent);
4931
5157
  console.log('');
4932
5158
 
4933
- // Step 4: Environment checks
4934
- info('Step 4/5: Checking environment...');
5159
+ // Step 3: Environment checks
5160
+ info('Step 3/5: Checking environment...');
4935
5161
  let pythonCmd = null;
4936
5162
  if (agent !== 'openclaw') {
4937
5163
  pythonCmd = checkPython();
@@ -4940,29 +5166,48 @@ async function main() {
4940
5166
  checkVersionWhitelist(agent, version);
4941
5167
  console.log('');
4942
5168
 
4943
- // Step 5: Write hook configuration
4944
- info('Step 5/5: Writing hook configuration...');
5169
+ // Step 4: Write hook configuration
5170
+ info('Step 4/5: Writing hook configuration...');
5171
+ let written = {};
4945
5172
  if (agent === 'claude-code') {
4946
- writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd);
5173
+ // per-agent 路由时把配置写进 agent 的 workspace(仅该 agent 生效)。
5174
+ // 云端按 agentId 路由但 workspace 为空会导致 settings.json 落 ~/.claude、
5175
+ // settings.local.json 落进程 cwd —— per-agent 隔离失效。此时显式报错。
5176
+ if (args.agentId && !agentWorkspace) {
5177
+ errorBox([
5178
+ `ERROR: agent "${args.agentId}" 的 config.json 缺少 workspace 字段`,
5179
+ '',
5180
+ 'claude-code per-agent 配置需要明确的 workspace 目录,',
5181
+ '否则 hook 配置会落到全局/进程目录,无法做到 per-agent 隔离。',
5182
+ ]);
5183
+ }
5184
+ written = writeClaudeCodeHook(token, WORKSPACE_ID, pythonCmd, args.agentId ? agentWorkspace : undefined, args.cloud);
4947
5185
  } else if (agent === 'codex') {
4948
- writeCodexHook(token, WORKSPACE_ID, pythonCmd);
5186
+ // CODEX_HOME 来源优先级:--codex-home= > 环境变量 CODEX_HOME > ~/.codex(缺省)。
5187
+ const codexHome = args['codex-home'] || process.env.CODEX_HOME || undefined;
5188
+ if (codexHome) info(`Codex 配置目录: ${codexHome}`);
5189
+ written = writeCodexHook(token, WORKSPACE_ID, pythonCmd, codexHome, args.cloud);
4949
5190
  } else {
4950
- writeOpenClawHook(token, WORKSPACE_ID);
5191
+ // openclaw:云端用 traceAgentIds allowlist 做 per-agent 放行。
5192
+ written = writeOpenClawHook(token, WORKSPACE_ID, args.agentId) || {};
4951
5193
  }
5194
+ // 走到这里说明 detectAgent / 环境检查 / 写 hook 配置全部成功 → 注入成功。
5195
+ cloudResult.inject = 'ok';
4952
5196
  console.log('');
4953
5197
 
4954
- // Success summary
5198
+ // Success summary(用实际写入路径,per-agent / 自定义 CODEX_HOME 时也准确)
4955
5199
  const summaryLines = [
4956
5200
  `Agent: ${agent} v${version}`,
4957
5201
  ];
5202
+ if (args.agentId) summaryLines.push(`Agent ID: ${args.agentId}`);
4958
5203
  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`);
5204
+ summaryLines.push(`Hook script: ${written.hookScript || '~/.claude/hooks/cozeloop_hook.py'}`);
5205
+ summaryLines.push(`Config: ${written.settingsPath || '~/.claude/settings.json'}`);
5206
+ summaryLines.push(`Credentials: ${written.localSettingsPath || '.claude/settings.local.json'}`);
4962
5207
  } 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`);
5208
+ summaryLines.push(`Hook script: ${written.hookScript || '~/.codex/hooks/cozeloop_hook.py'}`);
5209
+ summaryLines.push(`Config: ${written.hooksJson || '~/.codex/hooks.json'}`);
5210
+ summaryLines.push(`Credentials: ${written.envFile || '~/.codex/hooks/cozeloop.env'}`);
4966
5211
  } else {
4967
5212
  summaryLines.push(`Config: ~/.openclaw/openclaw.json`);
4968
5213
  }
@@ -4979,9 +5224,52 @@ async function main() {
4979
5224
  info('确保 CozeLoop Token 具备 Trace ingest 权限,否则 hook 触发但上报会失败。');
4980
5225
  console.log('');
4981
5226
 
5227
+ // Step 5: Verify trace reporting end-to-end
5228
+ info('Step 5/5: 验证 trace 上报链路...');
5229
+ const verifyResult = await verifyTraceReport(token, WORKSPACE_ID, args.pairCode);
5230
+ if (verifyResult.success) {
5231
+ cloudResult.verify = 'ok';
5232
+ } else if (CLOUD_MODE) {
5233
+ // 云端:注入已成功,验证失败不阻断(放行),记录结果供后台弹 warning。
5234
+ cloudResult.verify = 'fail';
5235
+ cloudResult.logid = extractLogid(verifyResult.body) || cloudResult.logid;
5236
+ cloudResult.message = `trace 上报自检失败 HTTP ${verifyResult.status}: ${(verifyResult.body || '').slice(0, 200)}`;
5237
+ warn('trace 上报自检失败,但 hook 配置已写入(云端放行)。');
5238
+ console.log('');
5239
+ emitCloudResult();
5240
+ successBox(summaryLines);
5241
+ return;
5242
+ } else {
5243
+ errorBox([
5244
+ 'ERROR: trace 上报自检失败',
5245
+ '',
5246
+ `HTTP ${verifyResult.status}`,
5247
+ (verifyResult.body || '').slice(0, 300),
5248
+ '',
5249
+ 'hook 配置已写入,但上报链路未打通。',
5250
+ '请确认 CozeLoop Token 具备 Trace ingest 权限,再重试。',
5251
+ ]); // errorBox 内部 process.exit(1)
5252
+ }
5253
+ console.log('');
5254
+
5255
+ emitCloudResult();
4982
5256
  successBox(summaryLines);
4983
5257
  }
4984
5258
 
4985
5259
  main().catch(e => {
5260
+ // 云端模式:失败时输出结构化结果(含 logid),exit 非 0 但带 COZE_LAB_RESULT 行。
5261
+ if (CLOUD_MODE) {
5262
+ if (cloudResult.inject !== 'ok') {
5263
+ // 写 hook 配置前的任何失败 → 注入失败。
5264
+ cloudResult.inject = 'fail';
5265
+ } else if (cloudResult.verify === 'skip') {
5266
+ // 注入已成功,但验证阶段异常崩溃(未走到正常的 ok/fail 判定)→ 记为验证失败。
5267
+ cloudResult.verify = 'fail';
5268
+ }
5269
+ if (!cloudResult.message) cloudResult.message = e && e.message ? e.message : 'unexpected failure';
5270
+ if (!cloudResult.logid) cloudResult.logid = extractLogid(cloudResult.message);
5271
+ emitCloudResult();
5272
+ process.exit(1);
5273
+ }
4986
5274
  errorBox(['ERROR: Unexpected failure', '', e.message]);
4987
5275
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -224,7 +224,8 @@ def get_fresh_token() -> Optional[str]:
224
224
  if new_token:
225
225
  return new_token
226
226
  debug_log("Refresh failed, falling back to env var.")
227
- return os.environ.get("COZELOOP_API_TOKEN")
227
+ # Cloud sandbox: token lives in COZE_API_TOKEN (no credentials.json / refresh).
228
+ return os.environ.get("COZELOOP_API_TOKEN") or os.environ.get("COZE_API_TOKEN")
228
229
 
229
230
  # -------------------------------------------------------------------------
230
231
 
@@ -431,6 +432,29 @@ def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str,
431
432
  return steps
432
433
 
433
434
 
435
+ def _parse_ts(msg):
436
+ """从 transcript msg 的 timestamp 字段解析出 datetime(带时区)。失败返回 None。
437
+
438
+ Claude Code transcript 每条 user/assistant 消息都带 ISO8601 timestamp
439
+ (如 "2026-06-01T13:51:55.450Z")。建 span 时用它做 start_time,
440
+ 避免回放时所有 span 挤在几毫秒内、duration 全是几毫秒。
441
+ """
442
+ if not isinstance(msg, dict):
443
+ return None
444
+ ts = msg.get("timestamp")
445
+ if not ts or not isinstance(ts, str):
446
+ return None
447
+ try:
448
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
449
+ except Exception:
450
+ return None
451
+
452
+
453
+ def _ts_ms(dt):
454
+ """datetime → 毫秒时间戳(int);None → None。"""
455
+ return int(dt.timestamp() * 1000) if dt is not None else None
456
+
457
+
434
458
  def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
435
459
  """Group messages into conversation turns (user -> assistant -> tool_results).
436
460
 
@@ -793,13 +817,31 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
793
817
  client = cozeloop.new_client(**client_kwargs)
794
818
 
795
819
  try:
796
- with client.start_span(name="claude_code_request", span_type="main") as root_span:
820
+ # 整体时间范围:第一条 user 消息 → 最后一条 assistant 消息(真实 transcript 时间)
821
+ _all_ts = []
822
+ for _t in turns:
823
+ _u = _parse_ts(_t.get("user_message"))
824
+ if _u:
825
+ _all_ts.append(_u)
826
+ for _s in _t.get("steps", []):
827
+ _a = _parse_ts(_s.get("assistant_message"))
828
+ if _a:
829
+ _all_ts.append(_a)
830
+ root_start_dt = min(_all_ts) if _all_ts else None
831
+ root_end_dt = max(_all_ts) if _all_ts else None
832
+
833
+ with client.start_span(name="claude_code_request", span_type="main", start_time=root_start_dt) as root_span:
797
834
  root_span.set_runtime(Runtime(library="claude-code"))
798
835
  root_tags = {
799
836
  "thread_id": session_id,
800
837
  "total_turns": len(turns),
801
838
  "source": "claude_code"
802
839
  }
840
+ # 真实耗时写 tag(SDK finish 不支持显式结束时间,duration 用 tag 兜底)
841
+ if root_start_dt is not None and root_end_dt is not None:
842
+ root_tags["real_start_ms"] = _ts_ms(root_start_dt)
843
+ root_tags["real_end_ms"] = _ts_ms(root_end_dt)
844
+ root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
803
845
  root_baggage = {
804
846
  "thread_id": session_id,
805
847
  }
@@ -836,14 +878,29 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
836
878
  steps = turn.get("steps", [])
837
879
  total_steps = len(steps)
838
880
 
839
- with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
881
+ # turn 真实时间:start=user 消息时间,end=最后一个 step 的 assistant 时间
882
+ turn_start_dt = _parse_ts(turn.get("user_message"))
883
+ turn_end_dt = None
884
+ for _s in steps:
885
+ _a = _parse_ts(_s.get("assistant_message"))
886
+ if _a:
887
+ turn_end_dt = _a
888
+ if turn_start_dt is None:
889
+ turn_start_dt = turn_end_dt
890
+
891
+ with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
840
892
  turn_span.set_runtime(Runtime(library="claude-code"))
841
- turn_span.set_tags({
893
+ _turn_tags = {
842
894
  "thread_id": session_id,
843
895
  "turn_index": i,
844
896
  "total_steps": total_steps,
845
897
  "source": "claude_code",
846
- })
898
+ }
899
+ if turn_start_dt is not None and turn_end_dt is not None:
900
+ _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
901
+ _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
902
+ _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
903
+ turn_span.set_tags(_turn_tags)
847
904
 
848
905
  # Extract user input for this turn
849
906
  user_message = turn.get("user_message", {}).get("message", {})
@@ -861,10 +918,23 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
861
918
  raw_content = assistant_message_obj.get("content", [])
862
919
  model_name = assistant_message_obj.get("model", "claude-code")
863
920
 
921
+ # step 真实时间:start=本 step assistant 时间;end=下一 step assistant 时间(即本次模型调用+工具的耗时),无下一 step 则用 turn_end
922
+ step_start_dt = _parse_ts(assistant_msg)
923
+ if j + 1 < len(steps):
924
+ step_end_dt = _parse_ts(steps[j + 1].get("assistant_message")) or turn_end_dt
925
+ else:
926
+ step_end_dt = turn_end_dt
927
+
864
928
  # --- Create model span for this step ---
865
- with client.start_span(name=f"model_call_{j}", span_type="model") as model_span:
929
+ with client.start_span(name=f"model_call_{j}", span_type="model", start_time=step_start_dt) as model_span:
866
930
  model_span.set_runtime(Runtime(library="claude-code"))
867
931
  model_span.set_model_name(model_name)
932
+ if step_start_dt is not None and step_end_dt is not None:
933
+ model_span.set_tags({
934
+ "real_start_ms": _ts_ms(step_start_dt),
935
+ "real_end_ms": _ts_ms(step_end_dt),
936
+ "latency_ms": _ts_ms(step_end_dt) - _ts_ms(step_start_dt),
937
+ })
868
938
 
869
939
  # Set input: accumulated context up to this point
870
940
  model_span.set_input(ModelInput(
@@ -958,7 +1028,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
958
1028
  span_type = "agent" if is_agent else "tool"
959
1029
  span_name = f"agent_{tool_name}" if is_agent else f"tool_{tool_name}"
960
1030
 
961
- with client.start_span(name=span_name, span_type=span_type) as tool_span:
1031
+ with client.start_span(name=span_name, span_type=span_type, start_time=step_start_dt) as tool_span:
962
1032
  tool_span.set_runtime(Runtime(library="claude-code"))
963
1033
  tags = {
964
1034
  "tool_name": tool_name,
@@ -197,7 +197,8 @@ def get_fresh_token():
197
197
  new_token = _refresh_token(creds["refresh_token"])
198
198
  if new_token:
199
199
  return new_token
200
- return os.environ.get("COZELOOP_API_TOKEN")
200
+ # Cloud sandbox: token lives in COZE_API_TOKEN (no credentials.json / refresh).
201
+ return os.environ.get("COZELOOP_API_TOKEN") or os.environ.get("COZE_API_TOKEN")
201
202
  # -------------------------------------------------------------------------
202
203
 
203
204
  # --- SDK Import ---
@@ -415,6 +416,28 @@ def truncate_text(text: str, limit: int = 12000) -> str:
415
416
 
416
417
  # --- Message Grouping ---
417
418
 
419
+ def _parse_ts(obj):
420
+ """从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
421
+
422
+ Codex rollout JSONL 每条 entry 顶层带 ISO8601 timestamp。建 span 时用它做
423
+ start_time,避免回放时所有 span 挤在几毫秒内。
424
+ """
425
+ if not isinstance(obj, dict):
426
+ return None
427
+ ts = obj.get("timestamp") or obj.get("_ts")
428
+ if not ts or not isinstance(ts, str):
429
+ return None
430
+ try:
431
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
432
+ except Exception:
433
+ return None
434
+
435
+
436
+ def _ts_ms(dt):
437
+ """datetime → 毫秒时间戳(int);None → None。"""
438
+ return int(dt.timestamp() * 1000) if dt is not None else None
439
+
440
+
418
441
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
419
442
  """Group raw JSONL entries into conversation turns.
420
443
 
@@ -437,6 +460,9 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
437
460
  for entry in entries:
438
461
  entry_type = entry.get("type")
439
462
  payload = entry.get("payload", {})
463
+ # 把 entry 顶层 timestamp 注入 payload,供后续建 span 取真实时间
464
+ if isinstance(payload, dict) and entry.get("timestamp") and "_ts" not in payload:
465
+ payload["_ts"] = entry.get("timestamp")
440
466
 
441
467
  # --- Turn lifecycle events ---
442
468
  if entry_type == "event_msg":
@@ -645,13 +671,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
645
671
  ctx: List[Dict[str, Any]] = list(history_context) if history_context else []
646
672
 
647
673
  try:
648
- with client.start_span(name="codex_request", span_type="main") as root_span:
674
+ # 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
675
+ _all_ts = []
676
+ for _t in turns:
677
+ _u = _parse_ts(_t.get("user_message"))
678
+ if _u:
679
+ _all_ts.append(_u)
680
+ for _a in _t.get("assistant_messages", []):
681
+ _ad = _parse_ts(_a)
682
+ if _ad:
683
+ _all_ts.append(_ad)
684
+ root_start_dt = min(_all_ts) if _all_ts else None
685
+ root_end_dt = max(_all_ts) if _all_ts else None
686
+
687
+ with client.start_span(name="codex_request", span_type="main", start_time=root_start_dt) as root_span:
649
688
  root_span.set_runtime(Runtime(library="codex-cli"))
650
689
  root_tags = {
651
690
  "thread_id": session_id,
652
691
  "total_turns": len(turns),
653
692
  "source": "codex_cli",
654
693
  }
694
+ if root_start_dt is not None and root_end_dt is not None:
695
+ root_tags["real_start_ms"] = _ts_ms(root_start_dt)
696
+ root_tags["real_end_ms"] = _ts_ms(root_end_dt)
697
+ root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
655
698
  root_baggage = {
656
699
  "thread_id": session_id,
657
700
  }
@@ -689,20 +732,49 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
689
732
  # Process each turn
690
733
  for i, turn in enumerate(turns):
691
734
  try:
692
- with client.start_span(name=f"turn_{i}", span_type="main") as turn_span:
735
+ # turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
736
+ turn_start_dt = _parse_ts(turn.get("user_message"))
737
+ turn_end_dt = None
738
+ for _a in turn.get("assistant_messages", []):
739
+ _ad = _parse_ts(_a)
740
+ if _ad:
741
+ turn_end_dt = _ad
742
+ if turn_start_dt is None:
743
+ turn_start_dt = turn_end_dt
744
+
745
+ with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
693
746
  turn_span.set_runtime(Runtime(library="codex-cli"))
694
- turn_span.set_tags({
747
+ _turn_tags = {
695
748
  "thread_id": session_id,
696
749
  "turn_index": i,
697
750
  "turn_id": turn.get("turn_id", ""),
698
751
  "source": "codex_cli",
699
- })
752
+ }
753
+ if turn_start_dt is not None and turn_end_dt is not None:
754
+ _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
755
+ _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
756
+ _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
757
+ turn_span.set_tags(_turn_tags)
700
758
 
701
759
  # --- Model span for assistant response ---
702
760
  if turn.get("assistant_messages"):
703
- with client.start_span(name="assistant_response", span_type="model") as model_span:
761
+ # model span start:第一条 assistant payload 时间,回退到 turn 起点
762
+ _model_start_dt = None
763
+ for _a in turn.get("assistant_messages", []):
764
+ _model_start_dt = _parse_ts(_a)
765
+ if _model_start_dt:
766
+ break
767
+ if _model_start_dt is None:
768
+ _model_start_dt = turn_start_dt
769
+ with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
704
770
  model_span.set_runtime(Runtime(library="codex-cli"))
705
771
  model_span.set_model_name(model_name)
772
+ if _model_start_dt is not None and turn_end_dt is not None:
773
+ model_span.set_tags({
774
+ "real_start_ms": _ts_ms(_model_start_dt),
775
+ "real_end_ms": _ts_ms(turn_end_dt),
776
+ "latency_ms": _ts_ms(turn_end_dt) - _ts_ms(_model_start_dt),
777
+ })
706
778
 
707
779
  # Build input messages: history + current turn input
708
780
  turn_input = turn.get("input_messages", [])
@@ -770,7 +842,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
770
842
  # --- Tool call spans ---
771
843
  for tool_call in turn.get("tool_calls", []):
772
844
  tool_name = tool_call.get("name", "unknown")
773
- with client.start_span(name=f"tool_{tool_name}", span_type="tool") as tool_span:
845
+ with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=turn_start_dt) as tool_span:
774
846
  tool_span.set_runtime(Runtime(library="codex-cli"))
775
847
  tool_span.set_tags({
776
848
  "tool_name": tool_name,
@@ -794,7 +866,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
794
866
  agent_id = sc.get("agent_id") or "unknown"
795
867
  nickname = sc.get("nickname") or agent_id
796
868
 
797
- with client.start_span(name=f"subagent_{nickname}", span_type="agent") as subagent_span:
869
+ with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=turn_start_dt) as subagent_span:
798
870
  subagent_span.set_runtime(Runtime(library="codex-cli"))
799
871
  subagent_span.set_tags({
800
872
  "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 });
@@ -446,6 +446,32 @@ function resolveChannelId(ctx, eventFrom, defaultValue = "system/unknown") {
446
446
  let lastUserChannelId;
447
447
  let lastUserTraceContext;
448
448
  let lastOpenclawSessionId;
449
+ // Session-level coze-context cache. In a Coze session only the FIRST user
450
+ // message carries the <coze-context> block; /loop follow-ups and later turns
451
+ // do not. We cache the most recently seen context (per openclaw session id, plus
452
+ // a global last-seen fallback) so that ensureRootSpan can still attach the
453
+ // trace to the right Coze session on those later turns instead of dropping it.
454
+ const cozeContextBySession = new Map();
455
+ let lastCozeContext;
456
+ function rememberCozeContext(input, sessionId) {
457
+ const parsed = parseCozeContext(input);
458
+ if (Object.keys(parsed).length === 0)
459
+ return parsed;
460
+ lastCozeContext = parsed;
461
+ if (sessionId)
462
+ cozeContextBySession.set(sessionId, parsed);
463
+ return parsed;
464
+ }
465
+ function resolveCozeContext(input, sessionId) {
466
+ // Prefer context parsed from this turn's input; fall back to the per-session
467
+ // cache, then the global last-seen context.
468
+ const fromInput = parseCozeContext(input);
469
+ if (Object.keys(fromInput).length > 0)
470
+ return fromInput;
471
+ if (sessionId && cozeContextBySession.has(sessionId))
472
+ return cozeContextBySession.get(sessionId);
473
+ return lastCozeContext || {};
474
+ }
449
475
  // Active agent context: set in before_agent_start, cleared in agent_end.
450
476
  // All hooks between these two (llm_input, llm_output, tool calls, messages)
451
477
  // use this to ensure every span lands in the same Trace.
@@ -485,6 +511,31 @@ const cozeloopTracePlugin = {
485
511
  enabledHooks: pluginConfig.enabledHooks,
486
512
  };
487
513
  const exporter = new CozeloopExporter(api, config);
514
+ // per-agent trace 放行:traceAgentIds 为 onboard 写入的 allowlist(小写归一)。
515
+ // 非空时,仅 allowlist 内的 agentId 上报 trace;为空 / 未配置则全部放行(本地全局模式)。
516
+ const traceAgentIds = Array.isArray(pluginConfig.traceAgentIds)
517
+ ? pluginConfig.traceAgentIds.map((s) => String(s).trim().toLowerCase()).filter(Boolean)
518
+ : [];
519
+ const isAgentAllowed = (hookCtx) => {
520
+ if (traceAgentIds.length === 0)
521
+ return true; // 空 allowlist = 全部放行
522
+ const agentId = resolveAgentIdFromHookCtx(hookCtx || {});
523
+ const allowed = traceAgentIds.includes(agentId);
524
+ if (!allowed && config.debug) {
525
+ api.logger.info(`[CozeloopTrace] agent "${agentId}" not in traceAgentIds, skip trace`);
526
+ }
527
+ return allowed;
528
+ };
529
+ // 包裹 api.on:带 hookCtx 的 hook 在 agentId 不在 allowlist 时直接跳过,
530
+ // 不进入任何上报逻辑。gateway_start/stop 等无 hookCtx 的生命周期 hook 不拦截。
531
+ const rawOn = api.on.bind(api);
532
+ const on = (name, handler) => {
533
+ rawOn(name, async (event, hookCtx) => {
534
+ if (hookCtx !== undefined && !isAgentAllowed(hookCtx))
535
+ return;
536
+ return handler(event, hookCtx);
537
+ });
538
+ };
488
539
  const contextByChannelId = new Map();
489
540
  const contextByRunId = new Map();
490
541
  const shouldHookEnabled = (hookName) => {
@@ -761,6 +812,14 @@ const cozeloopTracePlugin = {
761
812
  });
762
813
  if (shouldHookEnabled("gateway_start")) {
763
814
  api.on("gateway_start", async (event) => {
815
+ // gateway_start 是进程级事件、无 agent 归属。云端 per-agent 模式
816
+ // (traceAgentIds 非空)下不上报这条无归属 span,避免越过 allowlist。
817
+ if (traceAgentIds.length > 0) {
818
+ if (config.debug) {
819
+ api.logger.info("[CozeloopTrace] traceAgentIds set, skip gateway_start span");
820
+ }
821
+ return;
822
+ }
764
823
  const now = Date.now();
765
824
  const { ctx, channelId } = getOrCreateContext("system/gateway", undefined, "gateway_start");
766
825
  const span = createSpan(ctx, channelId, "gateway_start", "gateway", now, now, {
@@ -774,7 +833,7 @@ const cozeloopTracePlugin = {
774
833
  });
775
834
  }
776
835
  if (shouldHookEnabled("session_start")) {
777
- api.on("session_start", async (event, hookCtx) => {
836
+ on("session_start", async (event, hookCtx) => {
778
837
  // Refresh token if expiring soon (< 10 min)
779
838
  await exporter.refreshAuthIfNeeded();
780
839
  const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
@@ -791,7 +850,7 @@ const cozeloopTracePlugin = {
791
850
  });
792
851
  }
793
852
  if (shouldHookEnabled("message_received")) {
794
- api.on("message_received", async (event, hookCtx) => {
853
+ on("message_received", async (event, hookCtx) => {
795
854
  const rawChannelId = resolveChannelId(hookCtx, event.from || event.metadata?.senderId);
796
855
  if (config.debug) {
797
856
  api.logger.info(`[CozeloopTrace] message_received hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.from=${event.from}`);
@@ -814,6 +873,9 @@ const cozeloopTracePlugin = {
814
873
  lastUserTraceContext = ctx;
815
874
  ctx.userInput = event.content;
816
875
  lastUserInput = event.content;
876
+ // Cache any coze-context as soon as we see the user input,
877
+ // so later /loop turns (which carry none) can inherit it.
878
+ rememberCozeContext(event.content, ctx.openclawSessionId || lastOpenclawSessionId);
817
879
  if (config.debug) {
818
880
  api.logger.info(`[CozeloopTrace] Saved user context: channelId=${channelId}, traceId=${ctx.traceId}`);
819
881
  }
@@ -829,7 +891,7 @@ const cozeloopTracePlugin = {
829
891
  });
830
892
  }
831
893
  if (shouldHookEnabled("message_sending")) {
832
- api.on("message_sending", async (event, hookCtx) => {
894
+ on("message_sending", async (event, hookCtx) => {
833
895
  if (lastUserTraceContext) {
834
896
  lastUserTraceContext.lastOutput = event.content;
835
897
  if (config.debug) {
@@ -847,7 +909,7 @@ const cozeloopTracePlugin = {
847
909
  });
848
910
  }
849
911
  if (shouldHookEnabled("message_sent")) {
850
- api.on("message_sent", async (event, hookCtx) => {
912
+ on("message_sent", async (event, hookCtx) => {
851
913
  if (event.content && event.success) {
852
914
  if (lastUserTraceContext) {
853
915
  lastUserTraceContext.lastOutput = event.content;
@@ -870,7 +932,7 @@ const cozeloopTracePlugin = {
870
932
  let lastLlmStartTime = undefined;
871
933
  let lastLlmSpanId = undefined;
872
934
  if (shouldHookEnabled("llm_input")) {
873
- api.on("llm_input", async (event, hookCtx) => {
935
+ on("llm_input", async (event, hookCtx) => {
874
936
  const rawChannelId = resolveChannelId(hookCtx);
875
937
  if (config.debug) {
876
938
  api.logger.info(`[CozeloopTrace] llm_input hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, event.runId=${event.runId}`);
@@ -895,6 +957,9 @@ const cozeloopTracePlugin = {
895
957
  lastUserInput = event.prompt;
896
958
  }
897
959
  }
960
+ // Cache coze-context from the prompt too (covers sessions where no
961
+ // message_received fired), so /loop follow-ups can inherit it.
962
+ rememberCozeContext(event.prompt, ctx.openclawSessionId || lastOpenclawSessionId);
898
963
  // Fallback: ensure root + agent spans exist in case before_agent_start
899
964
  // was not fired (older OpenClaw versions or resumed sessions).
900
965
  const channelIdForSpans = activeAgentChannelId || rawChannelId;
@@ -958,7 +1023,7 @@ const cozeloopTracePlugin = {
958
1023
  });
959
1024
  }
960
1025
  if (shouldHookEnabled("llm_output")) {
961
- api.on("llm_output", async (event, hookCtx) => {
1026
+ on("llm_output", async (event, hookCtx) => {
962
1027
  const rawChannelId = resolveChannelId(hookCtx);
963
1028
  if (config.debug) {
964
1029
  api.logger.info(`[CozeloopTrace][DEBUG] llm_output event.usage=${JSON.stringify(event.usage)}`);
@@ -1054,7 +1119,7 @@ const cozeloopTracePlugin = {
1054
1119
  });
1055
1120
  }
1056
1121
  if (shouldHookEnabled("before_tool_call")) {
1057
- api.on("before_tool_call", async (event, hookCtx) => {
1122
+ on("before_tool_call", async (event, hookCtx) => {
1058
1123
  const rawChannelId = resolveChannelId(hookCtx);
1059
1124
  if (config.debug) {
1060
1125
  api.logger.info(`[CozeloopTrace] before_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
@@ -1075,7 +1140,7 @@ const cozeloopTracePlugin = {
1075
1140
  });
1076
1141
  }
1077
1142
  if (shouldHookEnabled("after_tool_call")) {
1078
- api.on("after_tool_call", async (event, hookCtx) => {
1143
+ on("after_tool_call", async (event, hookCtx) => {
1079
1144
  if (config.debug) {
1080
1145
  api.logger.info(`[CozeloopTrace] after_tool_call hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}, toolName=${event.toolName}`);
1081
1146
  }
@@ -1162,13 +1227,46 @@ const cozeloopTracePlugin = {
1162
1227
  traceFinalized = false;
1163
1228
  }, 200);
1164
1229
  };
1230
+ // OpenClaw runtime 周期性发送的心跳轮询消息,不是真实对话,整条 trace 丢弃。
1231
+ const HEARTBEAT_INPUT = "[OpenClaw heartbeat poll]";
1232
+ const isHeartbeatInput = (input) => {
1233
+ if (typeof input === "string") {
1234
+ return input.trim() === HEARTBEAT_INPUT;
1235
+ }
1236
+ // content 可能是 [{type:'text', text:'...'}] 形式
1237
+ if (Array.isArray(input)) {
1238
+ return input.some((p) => p && typeof p === "object"
1239
+ && typeof p.text === "string" && p.text.trim() === HEARTBEAT_INPUT);
1240
+ }
1241
+ return false;
1242
+ };
1165
1243
  // Helper: ensure root openclaw_request span is started for a given context.
1166
1244
  // Must be called before creating the agent span so that the exporter's
1167
1245
  // currentRootContext is set and the agent span becomes a proper child.
1168
1246
  const ensureRootSpan = async (ctx, channelId) => {
1169
- // Only trace sessions that carry coze-context no context means this
1170
- // is not a Coze-originated session and we skip it entirely.
1171
- if (!Object.keys(parseCozeContext(ctx.userInput)).length) {
1247
+ // 心跳轮询消息:不是真实对话,整条 trace 不上报(沿用下方“无 coze-context return”的同款范式)。
1248
+ const heartbeatInput = ctx.userInput
1249
+ || lastUserTraceContext?.userInput || lastUserInput;
1250
+ if (isHeartbeatInput(heartbeatInput)) {
1251
+ if (config.debug) {
1252
+ api.logger.info(`[CozeloopTrace] skip heartbeat poll trace, traceId=${ctx.traceId}`);
1253
+ }
1254
+ return;
1255
+ }
1256
+ // Resolve coze-context with SESSION-LEVEL inheritance: only the first
1257
+ // user message of a Coze session carries the <coze-context> block, so
1258
+ // /loop follow-ups and later turns have none. Fall back to the per-
1259
+ // session cache (and global last-seen) instead of dropping the trace.
1260
+ // Only skip when NO context has ever been seen for this session.
1261
+ const ocSessionId = ctx.openclawSessionId || lastOpenclawSessionId;
1262
+ rememberCozeContext(ctx.userInput, ocSessionId);
1263
+ let cozeCtx = resolveCozeContext(ctx.userInput, ocSessionId);
1264
+ if (Object.keys(cozeCtx).length === 0) {
1265
+ // Try the fallback user inputs too before giving up.
1266
+ const fallbackInput = lastUserTraceContext?.userInput || lastUserInput;
1267
+ cozeCtx = resolveCozeContext(fallbackInput, ocSessionId);
1268
+ }
1269
+ if (Object.keys(cozeCtx).length === 0) {
1172
1270
  return;
1173
1271
  }
1174
1272
  // Check both: rootSpanStartTime indicates we created a root span before,
@@ -1207,7 +1305,7 @@ const cozeloopTracePlugin = {
1207
1305
  "run.id": ctx.runId,
1208
1306
  "turn.id": ctx.turnId,
1209
1307
  "openclaw.channel_id": channelId,
1210
- ...parseCozeContext(ctx.userInput),
1308
+ ...cozeCtx,
1211
1309
  },
1212
1310
  input: ctx.userInput,
1213
1311
  traceId: ctx.traceId,
@@ -1237,7 +1335,7 @@ const cozeloopTracePlugin = {
1237
1335
  "run.id": ctx.runId,
1238
1336
  "turn.id": ctx.turnId,
1239
1337
  "openclaw.channel_id": channelId,
1240
- ...parseCozeContext(ctx.userInput),
1338
+ ...resolveCozeContext(ctx.userInput, ctx.openclawSessionId || lastOpenclawSessionId),
1241
1339
  },
1242
1340
  traceId: ctx.traceId,
1243
1341
  spanId: ctx.agentSpanId,
@@ -1252,7 +1350,7 @@ const cozeloopTracePlugin = {
1252
1350
  }
1253
1351
  };
1254
1352
  if (shouldHookEnabled("before_agent_start")) {
1255
- api.on("before_agent_start", async (event, hookCtx) => {
1353
+ on("before_agent_start", async (event, hookCtx) => {
1256
1354
  const rawChannelId = resolveChannelId(hookCtx);
1257
1355
  const agentId = hookCtx.agentId || event.agentId || "main";
1258
1356
  if (config.debug) {
@@ -1270,7 +1368,7 @@ const cozeloopTracePlugin = {
1270
1368
  });
1271
1369
  }
1272
1370
  if (shouldHookEnabled("agent_end")) {
1273
- api.on("agent_end", async (event, hookCtx) => {
1371
+ on("agent_end", async (event, hookCtx) => {
1274
1372
  const rawChannelId = resolveChannelId(hookCtx);
1275
1373
  if (config.debug) {
1276
1374
  api.logger.info(`[CozeloopTrace] agent_end hookCtx: ${JSON.stringify({ channelId: hookCtx.channelId, sessionKey: hookCtx.sessionKey, conversationId: hookCtx.conversationId })}`);
@@ -1290,7 +1388,7 @@ const cozeloopTracePlugin = {
1290
1388
  // versions), finalize the trace here so that agent + root spans get ended
1291
1389
  // and exported.
1292
1390
  if (shouldHookEnabled("session_end")) {
1293
- api.on("session_end", async (event, hookCtx) => {
1391
+ on("session_end", async (event, hookCtx) => {
1294
1392
  const rawChannelId = resolveChannelId(hookCtx, event.sessionId);
1295
1393
  if (config.debug) {
1296
1394
  api.logger.info(`[CozeloopTrace] session_end: ${rawChannelId}`);