@respan/cli 0.5.3 → 0.6.1

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.
@@ -0,0 +1,793 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+
25
+ // src/hooks/codex-cli.ts
26
+ var fs2 = __toESM(require("node:fs"), 1);
27
+ var os2 = __toESM(require("node:os"), 1);
28
+ var path2 = __toESM(require("node:path"), 1);
29
+
30
+ // src/hooks/shared.ts
31
+ var fs = __toESM(require("node:fs"), 1);
32
+ var os = __toESM(require("node:os"), 1);
33
+ var path = __toESM(require("node:path"), 1);
34
+ function resolveTracesEndpoint(baseUrl) {
35
+ const DEFAULT = "https://api.respan.ai/api/v2/traces";
36
+ if (!baseUrl) return DEFAULT;
37
+ const normalized = baseUrl.replace(/\/+$/, "");
38
+ if (normalized.endsWith("/api")) return `${normalized}/v2/traces`;
39
+ return `${normalized}/api/v2/traces`;
40
+ }
41
+ var _logFile = null;
42
+ var _debug = false;
43
+ function initLogging(logFile, debug2) {
44
+ _logFile = logFile;
45
+ _debug = debug2;
46
+ const dir = path.dirname(logFile);
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ }
49
+ function log(level, message) {
50
+ if (!_logFile) return;
51
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
52
+ fs.appendFileSync(_logFile, `${ts} [${level}] ${message}
53
+ `);
54
+ }
55
+ function debug(message) {
56
+ if (_debug) log("DEBUG", message);
57
+ }
58
+ var DEFAULT_BASE_URL = "https://api.respan.ai/api";
59
+ function resolveCredentials() {
60
+ let apiKey = process.env.RESPAN_API_KEY ?? "";
61
+ let baseUrl = process.env.RESPAN_BASE_URL ?? DEFAULT_BASE_URL;
62
+ if (!apiKey) {
63
+ const credsFile = path.join(os.homedir(), ".respan", "credentials.json");
64
+ if (fs.existsSync(credsFile)) {
65
+ try {
66
+ const creds = JSON.parse(fs.readFileSync(credsFile, "utf-8"));
67
+ const configFile = path.join(os.homedir(), ".respan", "config.json");
68
+ let profile = "default";
69
+ if (fs.existsSync(configFile)) {
70
+ const cfg = JSON.parse(fs.readFileSync(configFile, "utf-8"));
71
+ profile = cfg.activeProfile ?? "default";
72
+ }
73
+ const cred = creds[profile] ?? {};
74
+ apiKey = cred.apiKey ?? cred.accessToken ?? "";
75
+ if (!baseUrl || baseUrl === DEFAULT_BASE_URL) {
76
+ baseUrl = cred.baseUrl ?? baseUrl;
77
+ }
78
+ if (baseUrl && !baseUrl.replace(/\/+$/, "").endsWith("/api")) {
79
+ baseUrl = baseUrl.replace(/\/+$/, "") + "/api";
80
+ }
81
+ if (apiKey) {
82
+ debug(`Using API key from credentials.json (profile: ${profile})`);
83
+ }
84
+ } catch (e) {
85
+ debug(`Failed to read credentials.json: ${e}`);
86
+ }
87
+ }
88
+ }
89
+ if (!apiKey) return null;
90
+ return { apiKey, baseUrl };
91
+ }
92
+ var KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
93
+ "customer_id",
94
+ "span_name",
95
+ "workflow_name",
96
+ "base_url",
97
+ "project_id"
98
+ ]);
99
+ function loadRespanConfig(configPath) {
100
+ if (!fs.existsSync(configPath)) {
101
+ return { fields: {}, properties: {} };
102
+ }
103
+ try {
104
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
105
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
106
+ return { fields: {}, properties: {} };
107
+ }
108
+ const fields = {};
109
+ const properties = {};
110
+ for (const [k, v] of Object.entries(raw)) {
111
+ if (KNOWN_CONFIG_KEYS.has(k)) {
112
+ fields[k] = String(v);
113
+ } else {
114
+ properties[k] = String(v);
115
+ }
116
+ }
117
+ return { fields, properties };
118
+ } catch (e) {
119
+ debug(`Failed to load config from ${configPath}: ${e}`);
120
+ return { fields: {}, properties: {} };
121
+ }
122
+ }
123
+ function loadState(statePath) {
124
+ if (!fs.existsSync(statePath)) return {};
125
+ try {
126
+ return JSON.parse(fs.readFileSync(statePath, "utf-8"));
127
+ } catch {
128
+ return {};
129
+ }
130
+ }
131
+ function saveState(statePath, state) {
132
+ const dir = path.dirname(statePath);
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ const tmpPath = statePath + ".tmp." + process.pid;
135
+ try {
136
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
137
+ fs.renameSync(tmpPath, statePath);
138
+ } catch (e) {
139
+ try {
140
+ fs.unlinkSync(tmpPath);
141
+ } catch {
142
+ }
143
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
144
+ }
145
+ }
146
+ function acquireLock(lockPath, timeoutMs = 5e3) {
147
+ const deadline = Date.now() + timeoutMs;
148
+ while (Date.now() < deadline) {
149
+ try {
150
+ fs.mkdirSync(lockPath);
151
+ return () => {
152
+ try {
153
+ fs.rmdirSync(lockPath);
154
+ } catch {
155
+ }
156
+ };
157
+ } catch {
158
+ const waitMs = Math.min(100, deadline - Date.now());
159
+ if (waitMs > 0) {
160
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
161
+ }
162
+ }
163
+ }
164
+ debug("Could not acquire lock within timeout, proceeding without lock");
165
+ return () => {
166
+ };
167
+ }
168
+ function nowISO() {
169
+ return (/* @__PURE__ */ new Date()).toISOString();
170
+ }
171
+ function parseTimestamp(ts) {
172
+ try {
173
+ const d = new Date(ts);
174
+ return isNaN(d.getTime()) ? null : d;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+ function latencySeconds(start, end) {
180
+ const s = parseTimestamp(start);
181
+ const e = parseTimestamp(end);
182
+ if (s && e) return (e.getTime() - s.getTime()) / 1e3;
183
+ return void 0;
184
+ }
185
+ function truncate(text, maxChars = 4e3) {
186
+ if (text.length <= maxChars) return text;
187
+ return text.slice(0, maxChars) + "\n... (truncated)";
188
+ }
189
+ function addDefaultsToAll(spans) {
190
+ return spans;
191
+ }
192
+ function resolveSpanFields(config, defaults) {
193
+ const fields = config?.fields ?? {};
194
+ return {
195
+ workflowName: process.env.RESPAN_WORKFLOW_NAME ?? fields.workflow_name ?? defaults.workflowName,
196
+ spanName: process.env.RESPAN_SPAN_NAME ?? fields.span_name ?? defaults.spanName,
197
+ customerId: process.env.RESPAN_CUSTOMER_ID ?? fields.customer_id ?? ""
198
+ };
199
+ }
200
+ function buildMetadata(config, base = {}) {
201
+ const metadata = { ...base };
202
+ if (config?.properties) {
203
+ Object.assign(metadata, config.properties);
204
+ }
205
+ const envMetadata = process.env.RESPAN_METADATA;
206
+ if (envMetadata) {
207
+ try {
208
+ const extra = JSON.parse(envMetadata);
209
+ if (typeof extra === "object" && extra !== null) {
210
+ Object.assign(metadata, extra);
211
+ }
212
+ } catch {
213
+ }
214
+ }
215
+ return metadata;
216
+ }
217
+ function toOtlpValue(value) {
218
+ if (value === null || value === void 0) return null;
219
+ if (typeof value === "string") return { stringValue: value };
220
+ if (typeof value === "number") {
221
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
222
+ }
223
+ if (typeof value === "boolean") return { boolValue: value };
224
+ if (Array.isArray(value)) {
225
+ const values = value.map(toOtlpValue).filter(Boolean);
226
+ return { arrayValue: { values } };
227
+ }
228
+ if (typeof value === "object") {
229
+ const values = Object.entries(value).map(([k, v]) => {
230
+ const converted = toOtlpValue(v);
231
+ return converted ? { key: k, value: converted } : null;
232
+ }).filter(Boolean);
233
+ return { kvlistValue: { values } };
234
+ }
235
+ return { stringValue: String(value) };
236
+ }
237
+ function toOtlpAttributes(attrs) {
238
+ const result = [];
239
+ for (const [key, value] of Object.entries(attrs)) {
240
+ if (value === null || value === void 0) continue;
241
+ const converted = toOtlpValue(value);
242
+ if (converted) result.push({ key, value: converted });
243
+ }
244
+ return result;
245
+ }
246
+ function isoToNanos(iso) {
247
+ const d = new Date(iso);
248
+ if (isNaN(d.getTime())) return "0";
249
+ return String(BigInt(d.getTime()) * 1000000n);
250
+ }
251
+ function stringToTraceId(s) {
252
+ let hash = 0;
253
+ for (let i = 0; i < s.length; i++) {
254
+ hash = (hash << 5) - hash + s.charCodeAt(i) | 0;
255
+ }
256
+ let hash2 = 0;
257
+ for (let i = s.length - 1; i >= 0; i--) {
258
+ hash2 = (hash2 << 7) - hash2 + s.charCodeAt(i) | 0;
259
+ }
260
+ const hex1 = (hash >>> 0).toString(16).padStart(8, "0");
261
+ const hex2 = (hash2 >>> 0).toString(16).padStart(8, "0");
262
+ const hex3 = (s.length * 2654435761 >>> 0).toString(16).padStart(8, "0");
263
+ const hex4 = ((hash ^ hash2) >>> 0).toString(16).padStart(8, "0");
264
+ return (hex1 + hex2 + hex3 + hex4).slice(0, 32);
265
+ }
266
+ function stringToSpanId(s) {
267
+ let hash = 0;
268
+ for (let i = 0; i < s.length; i++) {
269
+ hash = (hash << 5) - hash + s.charCodeAt(i) | 0;
270
+ }
271
+ const hex1 = (hash >>> 0).toString(16).padStart(8, "0");
272
+ let hash2 = 0;
273
+ for (let i = s.length - 1; i >= 0; i--) {
274
+ hash2 = (hash2 << 3) - hash2 + s.charCodeAt(i) | 0;
275
+ }
276
+ const hex2 = (hash2 >>> 0).toString(16).padStart(8, "0");
277
+ return (hex1 + hex2).slice(0, 16);
278
+ }
279
+ function toOtlpPayload(spans) {
280
+ const otlpSpans = spans.map((span) => {
281
+ const attrs = {};
282
+ if (span.thread_identifier) attrs["respan.threads.thread_identifier"] = span.thread_identifier;
283
+ if (span.customer_identifier) attrs["respan.customer_params.customer_identifier"] = span.customer_identifier;
284
+ if (span.span_workflow_name) attrs["traceloop.workflow.name"] = span.span_workflow_name;
285
+ if (span.span_path) attrs["traceloop.entity.path"] = span.span_path;
286
+ const isRoot = !span.span_parent_id;
287
+ const isLlm = span.span_name.includes(".chat");
288
+ const isTool = span.span_name.startsWith("Tool:");
289
+ const isThinking = span.span_name.startsWith("Thinking") || span.span_name === "Reasoning";
290
+ if (isLlm) {
291
+ attrs["traceloop.span.kind"] = "task";
292
+ attrs["llm.request.type"] = "chat";
293
+ } else if (isTool) {
294
+ attrs["traceloop.span.kind"] = "tool";
295
+ } else if (isRoot) {
296
+ attrs["traceloop.span.kind"] = "workflow";
297
+ } else if (isThinking) {
298
+ attrs["traceloop.span.kind"] = "task";
299
+ }
300
+ if (span.model) attrs["gen_ai.request.model"] = span.model;
301
+ if (span.provider_id) attrs["gen_ai.system"] = span.provider_id;
302
+ if (span.prompt_tokens !== void 0) attrs["gen_ai.usage.prompt_tokens"] = span.prompt_tokens;
303
+ if (span.completion_tokens !== void 0) attrs["gen_ai.usage.completion_tokens"] = span.completion_tokens;
304
+ if (span.total_tokens !== void 0) attrs["llm.usage.total_tokens"] = span.total_tokens;
305
+ if (span.input) attrs["traceloop.entity.input"] = span.input;
306
+ if (span.output) attrs["traceloop.entity.output"] = span.output;
307
+ if (span.metadata && Object.keys(span.metadata).length > 0) {
308
+ attrs["respan.metadata"] = JSON.stringify(span.metadata);
309
+ }
310
+ attrs["respan.entity.log_method"] = "ts_tracing";
311
+ const startNanos = isoToNanos(span.start_time);
312
+ let endNanos = isoToNanos(span.timestamp);
313
+ if (startNanos === endNanos && span.latency && span.latency > 0) {
314
+ const startMs = new Date(span.start_time).getTime();
315
+ endNanos = String(BigInt(Math.round(startMs + span.latency * 1e3)) * 1000000n);
316
+ }
317
+ const otlpSpan = {
318
+ traceId: stringToTraceId(span.trace_unique_id),
319
+ spanId: stringToSpanId(span.span_unique_id),
320
+ name: span.span_name,
321
+ kind: isLlm ? 3 : 1,
322
+ // 3=CLIENT (LLM calls), 1=INTERNAL
323
+ startTimeUnixNano: startNanos,
324
+ endTimeUnixNano: endNanos,
325
+ attributes: toOtlpAttributes(attrs),
326
+ status: { code: 1 }
327
+ // STATUS_CODE_OK
328
+ };
329
+ if (span.span_parent_id) {
330
+ otlpSpan.parentSpanId = stringToSpanId(span.span_parent_id);
331
+ }
332
+ return otlpSpan;
333
+ });
334
+ return {
335
+ resourceSpans: [{
336
+ resource: {
337
+ attributes: toOtlpAttributes({
338
+ "service.name": "respan-cli-hooks"
339
+ })
340
+ },
341
+ scopeSpans: [{
342
+ scope: { name: "respan-cli-hooks", version: "0.5.3" },
343
+ spans: otlpSpans
344
+ }]
345
+ }]
346
+ };
347
+ }
348
+ async function sendSpans(spans, apiKey, baseUrl, context) {
349
+ const url = resolveTracesEndpoint(baseUrl);
350
+ const headers = {
351
+ "Authorization": `Bearer ${apiKey}`,
352
+ "Content-Type": "application/json",
353
+ "X-Respan-Dogfood": "1"
354
+ // Anti-recursion: prevent trace loops on ingest
355
+ };
356
+ const payload = toOtlpPayload(spans);
357
+ const body = JSON.stringify(payload);
358
+ const spanNames = spans.map((s) => s.span_name);
359
+ debug(`Sending ${spans.length} spans (${body.length} bytes) to ${url} for ${context}: ${spanNames.join(", ")}`);
360
+ if (_debug) {
361
+ const debugDir = _logFile ? path.dirname(_logFile) : os.tmpdir();
362
+ const debugFile = path.join(debugDir, `respan_spans_${context.replace(/\s+/g, "_")}.json`);
363
+ fs.writeFileSync(debugFile, body);
364
+ debug(`Dumped OTLP payload to ${debugFile}`);
365
+ }
366
+ for (let attempt = 0; attempt < 3; attempt++) {
367
+ try {
368
+ const controller = new AbortController();
369
+ const timeout = setTimeout(() => controller.abort(), 3e4);
370
+ const response = await fetch(url, {
371
+ method: "POST",
372
+ headers,
373
+ body,
374
+ signal: controller.signal
375
+ });
376
+ clearTimeout(timeout);
377
+ if (response.status < 400) {
378
+ const text = await response.text();
379
+ debug(`Sent ${spans.length} spans for ${context} (attempt ${attempt + 1}): ${text.slice(0, 300)}`);
380
+ return;
381
+ }
382
+ if (response.status < 500) {
383
+ const text = await response.text();
384
+ log("ERROR", `Spans rejected for ${context}: HTTP ${response.status} - ${text.slice(0, 200)}`);
385
+ return;
386
+ }
387
+ debug(`Server error for ${context} (attempt ${attempt + 1}), retrying...`);
388
+ await sleep(1e3);
389
+ } catch (e) {
390
+ if (attempt < 2) {
391
+ await sleep(1e3);
392
+ } else {
393
+ log("ERROR", `Failed to send spans for ${context}: ${e}`);
394
+ }
395
+ }
396
+ }
397
+ log("ERROR", `Failed to send ${spans.length} spans for ${context} after 3 attempts`);
398
+ }
399
+ function sleep(ms) {
400
+ return new Promise((resolve) => setTimeout(resolve, ms));
401
+ }
402
+
403
+ // src/hooks/codex-cli.ts
404
+ var STATE_DIR = path2.join(os2.homedir(), ".codex", "state");
405
+ var LOG_FILE = path2.join(STATE_DIR, "respan_hook.log");
406
+ var STATE_FILE = path2.join(STATE_DIR, "respan_state.json");
407
+ var LOCK_PATH = path2.join(STATE_DIR, "respan_hook.lock");
408
+ var DEBUG_MODE = (process.env.CODEX_RESPAN_DEBUG ?? "").toLowerCase() === "true";
409
+ var MAX_CHARS = parseInt(process.env.CODEX_RESPAN_MAX_CHARS ?? "4000", 10) || 4e3;
410
+ initLogging(LOG_FILE, DEBUG_MODE);
411
+ var TOOL_DISPLAY_NAMES = {
412
+ exec_command: "Shell",
413
+ apply_patch: "File Edit",
414
+ web_search: "Web Search"
415
+ };
416
+ function toolDisplayName(name) {
417
+ return TOOL_DISPLAY_NAMES[name] ?? name;
418
+ }
419
+ function formatToolInput(toolName, args) {
420
+ if (!args) return "";
421
+ try {
422
+ const parsed = typeof args === "string" ? JSON.parse(args) : args;
423
+ if (toolName === "exec_command" && typeof parsed === "object" && parsed !== null) {
424
+ const cmd = parsed.cmd ?? "";
425
+ const workdir = parsed.workdir ?? "";
426
+ return truncate(workdir ? `[${workdir}] Command: ${cmd}` : `Command: ${cmd}`, MAX_CHARS);
427
+ }
428
+ if (toolName === "apply_patch") return truncate(args, MAX_CHARS);
429
+ if (typeof parsed === "object") return truncate(JSON.stringify(parsed, null, 2), MAX_CHARS);
430
+ } catch {
431
+ }
432
+ return truncate(String(args), MAX_CHARS);
433
+ }
434
+ function formatToolOutput(output) {
435
+ if (!output) return "";
436
+ try {
437
+ const parsed = JSON.parse(output);
438
+ if (typeof parsed === "object" && parsed !== null && "output" in parsed) {
439
+ return truncate(String(parsed.output), MAX_CHARS);
440
+ }
441
+ } catch {
442
+ }
443
+ return truncate(output, MAX_CHARS);
444
+ }
445
+ function parseSession(lines) {
446
+ const events = [];
447
+ for (const line of lines) {
448
+ if (!line.trim()) continue;
449
+ try {
450
+ events.push(JSON.parse(line));
451
+ } catch {
452
+ }
453
+ }
454
+ return events;
455
+ }
456
+ function extractTurns(events) {
457
+ const turns = [];
458
+ let current = null;
459
+ for (const event of events) {
460
+ const evtType = String(event.type ?? "");
461
+ const payload = event.payload ?? {};
462
+ const timestamp = String(event.timestamp ?? "");
463
+ if (evtType === "event_msg") {
464
+ const msgType = String(payload.type ?? "");
465
+ if (msgType === "task_started") {
466
+ current = {
467
+ turn_id: String(payload.turn_id ?? ""),
468
+ start_time: timestamp,
469
+ end_time: "",
470
+ model: "",
471
+ cwd: "",
472
+ user_message: "",
473
+ assistant_message: "",
474
+ commentary: [],
475
+ tool_calls: [],
476
+ tool_outputs: [],
477
+ reasoning: false,
478
+ reasoning_text: "",
479
+ token_usage: {}
480
+ };
481
+ } else if (msgType === "task_complete" && current) {
482
+ current.end_time = timestamp;
483
+ turns.push(current);
484
+ current = null;
485
+ } else if (msgType === "user_message" && current) {
486
+ current.user_message = String(payload.message ?? "");
487
+ } else if (msgType === "agent_message" && current) {
488
+ const phase = String(payload.phase ?? "");
489
+ const message = String(payload.message ?? "");
490
+ if (phase === "final_answer") current.assistant_message = message;
491
+ else if (phase === "commentary") current.commentary.push(message);
492
+ } else if (msgType === "token_count" && current) {
493
+ const info = payload.info ?? {};
494
+ const lastUsage = info.last_token_usage ?? {};
495
+ if (Object.keys(lastUsage).length) current.token_usage = lastUsage;
496
+ }
497
+ } else if (evtType === "turn_context" && current) {
498
+ current.model = String(payload.model ?? "");
499
+ current.cwd = String(payload.cwd ?? "");
500
+ } else if (evtType === "response_item" && current) {
501
+ const itemType = String(payload.type ?? "");
502
+ if (itemType === "function_call" || itemType === "custom_tool_call") {
503
+ current.tool_calls.push({
504
+ name: String(payload.name ?? "unknown"),
505
+ arguments: String(itemType === "custom_tool_call" ? payload.input ?? "" : payload.arguments ?? ""),
506
+ call_id: String(payload.call_id ?? ""),
507
+ timestamp
508
+ });
509
+ } else if (itemType === "function_call_output" || itemType === "custom_tool_call_output") {
510
+ current.tool_outputs.push({
511
+ call_id: String(payload.call_id ?? ""),
512
+ output: String(payload.output ?? ""),
513
+ timestamp
514
+ });
515
+ } else if (itemType === "reasoning") {
516
+ if (current) {
517
+ current.reasoning = true;
518
+ const summary = String(payload.summary ?? payload.text ?? "");
519
+ if (summary) current.reasoning_text += (current.reasoning_text ? "\n" : "") + summary;
520
+ }
521
+ } else if (itemType === "web_search_call") {
522
+ const action = payload.action ?? {};
523
+ const syntheticId = `web_search_${timestamp}`;
524
+ current.tool_calls.push({
525
+ name: "web_search",
526
+ arguments: JSON.stringify({ query: action.query ?? "" }),
527
+ call_id: syntheticId,
528
+ timestamp
529
+ });
530
+ current.tool_outputs.push({
531
+ call_id: syntheticId,
532
+ output: `Search: ${action.query ?? ""}`,
533
+ timestamp
534
+ });
535
+ }
536
+ }
537
+ }
538
+ return turns;
539
+ }
540
+ function createSpans(sessionId, turnNum, turn, config) {
541
+ const spans = [];
542
+ const now = nowISO();
543
+ const startTimeStr = turn.start_time || now;
544
+ const endTimeStr = turn.end_time || now;
545
+ const lat = latencySeconds(startTimeStr, endTimeStr);
546
+ const promptMessages = [];
547
+ if (turn.user_message) promptMessages.push({ role: "user", content: turn.user_message });
548
+ const completionMessage = turn.assistant_message ? { role: "assistant", content: turn.assistant_message } : null;
549
+ const { workflowName, spanName, customerId } = resolveSpanFields(config, {
550
+ workflowName: "codex-cli",
551
+ spanName: "codex-cli"
552
+ });
553
+ const traceUniqueId = `${sessionId}_turn_${turnNum}`;
554
+ const threadId = `codexcli_${sessionId}`;
555
+ const baseMeta = { codex_cli_turn: turnNum };
556
+ if (turn.cwd) baseMeta.cwd = turn.cwd;
557
+ if (turn.commentary.length) baseMeta.commentary = turn.commentary.join("\n");
558
+ const metadata = buildMetadata(config, baseMeta);
559
+ const usageFields = {};
560
+ const tu = turn.token_usage;
561
+ if (Object.keys(tu).length) {
562
+ const pt = Number(tu.input_tokens ?? 0);
563
+ const ct = Number(tu.output_tokens ?? 0);
564
+ usageFields.prompt_tokens = pt;
565
+ usageFields.completion_tokens = ct;
566
+ usageFields.total_tokens = Number(tu.total_tokens ?? pt + ct) || pt + ct;
567
+ const cached = Number(tu.cached_input_tokens ?? 0);
568
+ if (cached > 0) usageFields.prompt_tokens_details = { cached_tokens: cached };
569
+ const reasoning = Number(tu.reasoning_output_tokens ?? 0);
570
+ if (reasoning > 0) metadata.reasoning_tokens = reasoning;
571
+ }
572
+ const rootSpanId = `codexcli_${traceUniqueId}_root`;
573
+ spans.push({
574
+ trace_unique_id: traceUniqueId,
575
+ thread_identifier: threadId,
576
+ customer_identifier: customerId,
577
+ span_unique_id: rootSpanId,
578
+ span_name: spanName,
579
+ span_workflow_name: workflowName,
580
+ model: turn.model || "gpt-5.4",
581
+ provider_id: "",
582
+ span_path: "",
583
+ input: promptMessages.length ? JSON.stringify(promptMessages) : "",
584
+ output: turn.assistant_message,
585
+ timestamp: endTimeStr,
586
+ start_time: startTimeStr,
587
+ metadata,
588
+ ...lat !== void 0 ? { latency: lat } : {}
589
+ });
590
+ spans.push({
591
+ trace_unique_id: traceUniqueId,
592
+ span_unique_id: `codexcli_${traceUniqueId}_gen`,
593
+ span_parent_id: rootSpanId,
594
+ span_name: "openai.chat",
595
+ span_workflow_name: workflowName,
596
+ span_path: "openai_chat",
597
+ model: turn.model || "gpt-5.4",
598
+ provider_id: "openai",
599
+ metadata: {},
600
+ input: promptMessages.length ? JSON.stringify(promptMessages) : "",
601
+ output: turn.assistant_message,
602
+ prompt_messages: promptMessages,
603
+ completion_message: completionMessage,
604
+ timestamp: endTimeStr,
605
+ start_time: startTimeStr,
606
+ ...lat !== void 0 ? { latency: lat } : {},
607
+ ...usageFields
608
+ });
609
+ const reasoningTokens = Number(tu.reasoning_output_tokens ?? 0);
610
+ if (turn.reasoning || reasoningTokens > 0) {
611
+ spans.push({
612
+ trace_unique_id: traceUniqueId,
613
+ span_unique_id: `codexcli_${traceUniqueId}_reasoning`,
614
+ span_parent_id: rootSpanId,
615
+ span_name: "Reasoning",
616
+ span_workflow_name: workflowName,
617
+ span_path: "reasoning",
618
+ provider_id: "",
619
+ metadata: reasoningTokens > 0 ? { reasoning_tokens: reasoningTokens } : {},
620
+ input: "",
621
+ output: turn.reasoning_text || (reasoningTokens > 0 ? `[Reasoning: ${reasoningTokens} tokens]` : "[Reasoning]"),
622
+ timestamp: endTimeStr,
623
+ start_time: startTimeStr
624
+ });
625
+ }
626
+ const outputMap = /* @__PURE__ */ new Map();
627
+ for (const to of turn.tool_outputs) {
628
+ if (to.call_id) outputMap.set(to.call_id, to);
629
+ }
630
+ let toolNum = 0;
631
+ for (const tc of turn.tool_calls) {
632
+ toolNum++;
633
+ const display = toolDisplayName(tc.name);
634
+ const outputData = outputMap.get(tc.call_id);
635
+ const toolEnd = outputData?.timestamp ?? endTimeStr;
636
+ const toolLat = latencySeconds(tc.timestamp, toolEnd);
637
+ spans.push({
638
+ trace_unique_id: traceUniqueId,
639
+ span_unique_id: `codexcli_${traceUniqueId}_tool_${toolNum}`,
640
+ span_parent_id: rootSpanId,
641
+ span_name: `Tool: ${display}`,
642
+ span_workflow_name: workflowName,
643
+ span_path: `tool_${tc.name.toLowerCase()}`,
644
+ provider_id: "",
645
+ metadata: {},
646
+ input: formatToolInput(tc.name, tc.arguments),
647
+ output: formatToolOutput(outputData?.output ?? ""),
648
+ timestamp: toolEnd,
649
+ start_time: tc.timestamp || startTimeStr,
650
+ ...toolLat !== void 0 ? { latency: toolLat } : {}
651
+ });
652
+ }
653
+ return addDefaultsToAll(spans);
654
+ }
655
+ function findSessionFile(sessionId) {
656
+ const sessionsDir = path2.join(os2.homedir(), ".codex", "sessions");
657
+ if (!fs2.existsSync(sessionsDir)) return null;
658
+ const walk = (dir) => {
659
+ const entries = fs2.readdirSync(dir).sort().reverse();
660
+ for (const entry of entries) {
661
+ const full = path2.join(dir, entry);
662
+ if (fs2.statSync(full).isDirectory()) {
663
+ const result = walk(full);
664
+ if (result) return result;
665
+ } else if (entry.endsWith(".jsonl") && entry.includes(sessionId)) {
666
+ return full;
667
+ }
668
+ }
669
+ return null;
670
+ };
671
+ return walk(sessionsDir);
672
+ }
673
+ function findLatestSessionFile() {
674
+ const sessionsDir = path2.join(os2.homedir(), ".codex", "sessions");
675
+ if (!fs2.existsSync(sessionsDir)) return null;
676
+ let latestFile = null;
677
+ let latestMtime = 0;
678
+ const walk = (dir) => {
679
+ for (const entry of fs2.readdirSync(dir)) {
680
+ const full = path2.join(dir, entry);
681
+ const stat = fs2.statSync(full);
682
+ if (stat.isDirectory()) walk(full);
683
+ else if (entry.endsWith(".jsonl") && stat.mtimeMs > latestMtime) {
684
+ latestMtime = stat.mtimeMs;
685
+ latestFile = full;
686
+ }
687
+ }
688
+ };
689
+ walk(sessionsDir);
690
+ if (!latestFile) return null;
691
+ try {
692
+ const firstLine = fs2.readFileSync(latestFile, "utf-8").split("\n")[0];
693
+ if (!firstLine) return null;
694
+ const firstMsg = JSON.parse(firstLine);
695
+ const payload = firstMsg.payload ?? {};
696
+ const sessionId = String(payload.id ?? path2.basename(latestFile, ".jsonl"));
697
+ return { sessionId, sessionFile: latestFile };
698
+ } catch {
699
+ return null;
700
+ }
701
+ }
702
+ async function main() {
703
+ const scriptStart = Date.now();
704
+ debug("Codex hook started");
705
+ if (process.argv.length < 3) {
706
+ debug("No argument provided (expected JSON payload in argv[2])");
707
+ process.exit(0);
708
+ }
709
+ let payload;
710
+ try {
711
+ payload = JSON.parse(process.argv[2]);
712
+ } catch (e) {
713
+ debug(`Invalid JSON in argv[2]: ${e}`);
714
+ process.exit(0);
715
+ }
716
+ const eventType = String(payload.type ?? "");
717
+ if (eventType !== "agent-turn-complete") {
718
+ debug(`Ignoring event type: ${eventType}`);
719
+ process.exit(0);
720
+ }
721
+ let sessionId = String(payload["thread-id"] ?? "");
722
+ if (!sessionId) {
723
+ debug("No thread-id in notify payload");
724
+ process.exit(0);
725
+ }
726
+ debug(`Processing notify: type=${eventType}, session=${sessionId}`);
727
+ const creds = resolveCredentials();
728
+ if (!creds) {
729
+ log("ERROR", "No API key found. Run: respan auth login");
730
+ process.exit(0);
731
+ }
732
+ let sessionFile = findSessionFile(sessionId);
733
+ if (!sessionFile) {
734
+ const latest = findLatestSessionFile();
735
+ if (latest) {
736
+ sessionId = latest.sessionId;
737
+ sessionFile = latest.sessionFile;
738
+ } else {
739
+ debug("No session file found");
740
+ process.exit(0);
741
+ }
742
+ }
743
+ const cwd = String(payload.cwd ?? "");
744
+ const config = cwd ? loadRespanConfig(path2.join(cwd, ".codex", "respan.json")) : null;
745
+ if (config) debug(`Loaded respan.json config from ${cwd}`);
746
+ const maxAttempts = 3;
747
+ let turns = 0;
748
+ try {
749
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
750
+ const unlock = acquireLock(LOCK_PATH);
751
+ try {
752
+ const state = loadState(STATE_FILE);
753
+ const sessionState = state[sessionId] ?? {};
754
+ const lastTurnCount = Number(sessionState.turn_count ?? 0);
755
+ const lines = fs2.readFileSync(sessionFile, "utf-8").trim().split("\n");
756
+ const events = parseSession(lines);
757
+ const allTurns = extractTurns(events);
758
+ if (allTurns.length > lastTurnCount) {
759
+ const newTurns = allTurns.slice(lastTurnCount);
760
+ for (const turn of newTurns) {
761
+ turns++;
762
+ const turnNum = lastTurnCount + turns;
763
+ const spans = createSpans(sessionId, turnNum, turn, config);
764
+ await sendSpans(spans, creds.apiKey, creds.baseUrl, `turn_${turnNum}`);
765
+ }
766
+ state[sessionId] = {
767
+ turn_count: lastTurnCount + turns,
768
+ updated: nowISO()
769
+ };
770
+ saveState(STATE_FILE, state);
771
+ }
772
+ } finally {
773
+ unlock?.();
774
+ }
775
+ if (turns > 0) break;
776
+ if (attempt < maxAttempts - 1) {
777
+ const delay = 500 * (attempt + 1);
778
+ debug(`No turns processed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay}ms...`);
779
+ await new Promise((r) => setTimeout(r, delay));
780
+ }
781
+ }
782
+ const duration = (Date.now() - scriptStart) / 1e3;
783
+ log("INFO", `Processed ${turns} turns in ${duration.toFixed(1)}s`);
784
+ if (duration > 180) log("WARN", `Hook took ${duration.toFixed(1)}s (>3min)`);
785
+ } catch (e) {
786
+ log("ERROR", `Failed to process session: ${e}`);
787
+ if (DEBUG_MODE) debug(String(e.stack ?? e));
788
+ }
789
+ }
790
+ main().catch((e) => {
791
+ log("ERROR", `Hook crashed: ${e}`);
792
+ process.exit(1);
793
+ });