@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 @@
1
+ export {};
@@ -0,0 +1,641 @@
1
+ /**
2
+ * Respan Hook for Claude Code
3
+ *
4
+ * Sends Claude Code conversation traces to Respan after each response.
5
+ * Uses Claude Code's Stop hook to capture transcripts and convert them to spans.
6
+ *
7
+ * Span tree per turn:
8
+ * Root (claude-code)
9
+ * ├── claude.chat (generation — model, tokens, messages)
10
+ * ├── Thinking 1 (if extended thinking is present)
11
+ * ├── Tool: Read (if tool use occurred)
12
+ * └── Tool: Write (if tool use occurred)
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import { initLogging, log, debug, resolveCredentials, loadRespanConfig, loadState, saveState, acquireLock, sendSpans, addDefaultsToAll, resolveSpanFields, buildMetadata, nowISO, latencySeconds, truncate, } from './shared.js';
18
+ // ── Config ────────────────────────────────────────────────────────
19
+ const STATE_DIR = path.join(os.homedir(), '.claude', 'state');
20
+ const LOG_FILE = path.join(STATE_DIR, 'respan_hook.log');
21
+ const STATE_FILE = path.join(STATE_DIR, 'respan_state.json');
22
+ const LOCK_PATH = path.join(STATE_DIR, 'respan_hook.lock');
23
+ const DEBUG_MODE = (process.env.CC_RESPAN_DEBUG ?? '').toLowerCase() === 'true';
24
+ const MAX_CHARS = parseInt(process.env.CC_RESPAN_MAX_CHARS ?? '4000', 10) || 4000;
25
+ initLogging(LOG_FILE, DEBUG_MODE);
26
+ function getContent(msg) {
27
+ if (msg.message && typeof msg.message === 'object') {
28
+ return msg.message.content;
29
+ }
30
+ return msg.content;
31
+ }
32
+ function isToolResult(msg) {
33
+ const content = getContent(msg);
34
+ if (Array.isArray(content)) {
35
+ return content.some((item) => typeof item === 'object' && item !== null && item.type === 'tool_result');
36
+ }
37
+ return false;
38
+ }
39
+ function getToolCalls(msg) {
40
+ const content = getContent(msg);
41
+ if (Array.isArray(content)) {
42
+ return content.filter((item) => typeof item === 'object' && item !== null && item.type === 'tool_use');
43
+ }
44
+ return [];
45
+ }
46
+ function getTextContent(msg) {
47
+ const content = getContent(msg);
48
+ if (typeof content === 'string')
49
+ return content;
50
+ if (Array.isArray(content)) {
51
+ return content
52
+ .map((item) => {
53
+ if (typeof item === 'string')
54
+ return item;
55
+ if (typeof item === 'object' && item !== null) {
56
+ if (item.type === 'text')
57
+ return String(item.text ?? '');
58
+ }
59
+ return '';
60
+ })
61
+ .filter(Boolean)
62
+ .join('\n');
63
+ }
64
+ return '';
65
+ }
66
+ function mergeAssistantParts(parts) {
67
+ if (parts.length === 0)
68
+ return {};
69
+ const merged = [];
70
+ for (const part of parts) {
71
+ const content = getContent(part);
72
+ if (Array.isArray(content))
73
+ merged.push(...content);
74
+ else if (content)
75
+ merged.push({ type: 'text', text: String(content) });
76
+ }
77
+ const result = { ...parts[0] };
78
+ if (result.message && typeof result.message === 'object') {
79
+ result.message = { ...result.message, content: merged };
80
+ }
81
+ else {
82
+ result.content = merged;
83
+ }
84
+ return result;
85
+ }
86
+ // ── Tool formatting ───────────────────────────────────────────────
87
+ function formatToolInput(toolName, toolInput) {
88
+ if (!toolInput)
89
+ return '';
90
+ const input = toolInput;
91
+ if (['Write', 'Edit', 'MultiEdit'].includes(toolName) && typeof input === 'object') {
92
+ const filePath = input.file_path ?? input.path ?? '';
93
+ const content = String(input.content ?? '');
94
+ let result = `File: ${filePath}\n`;
95
+ if (content) {
96
+ const preview = content.length > 2000 ? content.slice(0, 2000) + '...' : content;
97
+ result += `Content:\n${preview}`;
98
+ }
99
+ return truncate(result, MAX_CHARS);
100
+ }
101
+ if (toolName === 'Read' && typeof input === 'object') {
102
+ return `File: ${input.file_path ?? input.path ?? ''}`;
103
+ }
104
+ if (['Bash', 'Shell'].includes(toolName) && typeof input === 'object') {
105
+ return `Command: ${input.command ?? ''}`;
106
+ }
107
+ try {
108
+ return truncate(JSON.stringify(toolInput, null, 2), MAX_CHARS);
109
+ }
110
+ catch {
111
+ return truncate(String(toolInput), MAX_CHARS);
112
+ }
113
+ }
114
+ function formatToolOutput(toolName, toolOutput) {
115
+ if (!toolOutput)
116
+ return '';
117
+ if (typeof toolOutput === 'string')
118
+ return truncate(toolOutput, MAX_CHARS);
119
+ if (Array.isArray(toolOutput)) {
120
+ const parts = [];
121
+ let total = 0;
122
+ for (const item of toolOutput) {
123
+ if (typeof item === 'object' && item !== null) {
124
+ const obj = item;
125
+ if (obj.type === 'text') {
126
+ const text = String(obj.text ?? '');
127
+ if (total + text.length > MAX_CHARS) {
128
+ const remaining = MAX_CHARS - total;
129
+ if (remaining > 100)
130
+ parts.push(text.slice(0, remaining) + '... (truncated)');
131
+ break;
132
+ }
133
+ parts.push(text);
134
+ total += text.length;
135
+ }
136
+ else if (obj.type === 'image') {
137
+ parts.push('[Image output]');
138
+ }
139
+ }
140
+ else if (typeof item === 'string') {
141
+ if (total + item.length > MAX_CHARS)
142
+ break;
143
+ parts.push(item);
144
+ total += item.length;
145
+ }
146
+ }
147
+ return parts.join('\n');
148
+ }
149
+ try {
150
+ return truncate(JSON.stringify(toolOutput, null, 2), MAX_CHARS);
151
+ }
152
+ catch {
153
+ return truncate(String(toolOutput), MAX_CHARS);
154
+ }
155
+ }
156
+ // ── Span creation ─────────────────────────────────────────────────
157
+ function createSpans(sessionId, turnNum, userMsg, assistantMsgs, toolResults, config) {
158
+ const spans = [];
159
+ // Extract user data
160
+ const userText = getTextContent(userMsg);
161
+ const userTimestamp = String(userMsg.timestamp ?? '');
162
+ // Collect assistant text
163
+ const textParts = assistantMsgs.map(getTextContent).filter(Boolean);
164
+ const finalOutput = textParts.join('\n');
165
+ // Aggregate model, usage, timing
166
+ let model = 'claude';
167
+ let usage = null;
168
+ let requestId;
169
+ let stopReason;
170
+ let firstAssistantTs;
171
+ let lastAssistantTs;
172
+ for (const aMsg of assistantMsgs) {
173
+ if (typeof aMsg !== 'object' || !aMsg.message)
174
+ continue;
175
+ const msgObj = aMsg.message;
176
+ model = String(msgObj.model ?? model);
177
+ requestId = String(aMsg.requestId ?? requestId ?? '');
178
+ stopReason = String(msgObj.stop_reason ?? stopReason ?? '');
179
+ const ts = String(aMsg.timestamp ?? '');
180
+ if (ts) {
181
+ if (!firstAssistantTs)
182
+ firstAssistantTs = ts;
183
+ lastAssistantTs = ts;
184
+ }
185
+ const msgUsage = msgObj.usage;
186
+ if (msgUsage) {
187
+ if (!usage) {
188
+ usage = { ...msgUsage };
189
+ }
190
+ else {
191
+ for (const key of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
192
+ if (key in msgUsage) {
193
+ usage[key] = (usage[key] ?? 0) + Number(msgUsage[key]);
194
+ }
195
+ }
196
+ if (msgUsage.service_tier)
197
+ usage.service_tier = msgUsage.service_tier;
198
+ }
199
+ }
200
+ }
201
+ // Timing
202
+ const now = nowISO();
203
+ const startTimeStr = userTimestamp || firstAssistantTs || now;
204
+ const timestampStr = lastAssistantTs || firstAssistantTs || now;
205
+ const lat = latencySeconds(startTimeStr, timestampStr);
206
+ // Messages
207
+ const promptMessages = [];
208
+ if (userText)
209
+ promptMessages.push({ role: 'user', content: userText });
210
+ const completionMessage = finalOutput ? { role: 'assistant', content: finalOutput } : null;
211
+ // IDs & fields
212
+ const { workflowName, spanName, customerId } = resolveSpanFields(config, {
213
+ workflowName: 'claude-code',
214
+ spanName: 'claude-code',
215
+ });
216
+ const traceUniqueId = `${sessionId}_turn_${turnNum}`;
217
+ const threadId = `claudecode_${sessionId}`;
218
+ // Metadata
219
+ const metadata = buildMetadata(config, { claude_code_turn: turnNum });
220
+ if (requestId)
221
+ metadata.request_id = requestId;
222
+ if (stopReason)
223
+ metadata.stop_reason = stopReason;
224
+ // Usage
225
+ const usageFields = {};
226
+ if (usage) {
227
+ const pt = Number(usage.input_tokens ?? 0);
228
+ const ct = Number(usage.output_tokens ?? 0);
229
+ usageFields.prompt_tokens = pt;
230
+ usageFields.completion_tokens = ct;
231
+ if (pt + ct > 0)
232
+ usageFields.total_tokens = pt + ct;
233
+ const cacheCreation = Number(usage.cache_creation_input_tokens ?? 0);
234
+ const cacheRead = Number(usage.cache_read_input_tokens ?? 0);
235
+ if (cacheCreation > 0)
236
+ usageFields.prompt_tokens_details = { cache_creation_tokens: cacheCreation };
237
+ if (cacheRead > 0) {
238
+ usageFields.prompt_tokens_details = {
239
+ ...usageFields.prompt_tokens_details,
240
+ cached_tokens: cacheRead,
241
+ };
242
+ }
243
+ if (usage.service_tier)
244
+ metadata.service_tier = String(usage.service_tier);
245
+ }
246
+ // Root span
247
+ const rootSpanId = `claudecode_${traceUniqueId}_root`;
248
+ spans.push({
249
+ trace_unique_id: traceUniqueId,
250
+ thread_identifier: threadId,
251
+ customer_identifier: customerId,
252
+ span_unique_id: rootSpanId,
253
+ span_name: spanName,
254
+ span_workflow_name: workflowName,
255
+ model,
256
+ provider_id: '',
257
+ span_path: '',
258
+ input: promptMessages.length ? JSON.stringify(promptMessages) : '',
259
+ output: finalOutput,
260
+ timestamp: timestampStr,
261
+ start_time: startTimeStr,
262
+ metadata,
263
+ ...(lat !== undefined ? { latency: lat } : {}),
264
+ });
265
+ // LLM generation child span
266
+ const genStart = firstAssistantTs || startTimeStr;
267
+ const genEnd = lastAssistantTs || timestampStr;
268
+ const genLat = latencySeconds(genStart, genEnd);
269
+ spans.push({
270
+ trace_unique_id: traceUniqueId,
271
+ span_unique_id: `claudecode_${traceUniqueId}_gen`,
272
+ span_parent_id: rootSpanId,
273
+ span_name: 'claude.chat',
274
+ span_workflow_name: workflowName,
275
+ span_path: 'claude_chat',
276
+ model,
277
+ provider_id: 'anthropic',
278
+ metadata: {},
279
+ input: promptMessages.length ? JSON.stringify(promptMessages) : '',
280
+ output: finalOutput,
281
+ prompt_messages: promptMessages,
282
+ completion_message: completionMessage,
283
+ timestamp: genEnd,
284
+ start_time: genStart,
285
+ ...(genLat !== undefined ? { latency: genLat } : {}),
286
+ ...usageFields,
287
+ });
288
+ // Thinking child spans
289
+ let thinkingNum = 0;
290
+ for (const aMsg of assistantMsgs) {
291
+ if (typeof aMsg !== 'object' || !aMsg.message)
292
+ continue;
293
+ const content = aMsg.message.content;
294
+ if (!Array.isArray(content))
295
+ continue;
296
+ for (const item of content) {
297
+ if (typeof item === 'object' && item !== null && item.type === 'thinking') {
298
+ const thinkingText = String(item.thinking ?? '');
299
+ if (!thinkingText)
300
+ continue;
301
+ thinkingNum++;
302
+ const thinkingTs = String(aMsg.timestamp ?? timestampStr);
303
+ spans.push({
304
+ trace_unique_id: traceUniqueId,
305
+ span_unique_id: `claudecode_${traceUniqueId}_thinking_${thinkingNum}`,
306
+ span_parent_id: rootSpanId,
307
+ span_name: `Thinking ${thinkingNum}`,
308
+ span_workflow_name: workflowName,
309
+ span_path: 'thinking',
310
+ provider_id: '',
311
+ metadata: {},
312
+ input: '',
313
+ output: thinkingText,
314
+ timestamp: thinkingTs,
315
+ start_time: thinkingTs,
316
+ });
317
+ }
318
+ }
319
+ }
320
+ // Tool child spans
321
+ const toolCallMap = new Map();
322
+ for (const aMsg of assistantMsgs) {
323
+ for (const tc of getToolCalls(aMsg)) {
324
+ const id = String(tc.id ?? '');
325
+ toolCallMap.set(id, {
326
+ name: tc.name ?? 'unknown',
327
+ input: tc.input,
328
+ id,
329
+ timestamp: aMsg.timestamp,
330
+ });
331
+ }
332
+ }
333
+ for (const tr of toolResults) {
334
+ const trContent = getContent(tr);
335
+ const trMeta = {};
336
+ if (typeof tr === 'object' && tr.toolUseResult && typeof tr.toolUseResult === 'object') {
337
+ const tur = tr.toolUseResult;
338
+ for (const [src, dst] of [['durationMs', 'duration_ms'], ['numFiles', 'num_files'], ['filenames', 'filenames'], ['truncated', 'truncated']]) {
339
+ if (src in tur)
340
+ trMeta[dst] = tur[src];
341
+ }
342
+ }
343
+ if (Array.isArray(trContent)) {
344
+ for (const item of trContent) {
345
+ if (typeof item === 'object' && item !== null && item.type === 'tool_result') {
346
+ const toolUseId = String(item.tool_use_id ?? '');
347
+ const existing = toolCallMap.get(toolUseId);
348
+ if (existing) {
349
+ existing.output = item.content;
350
+ existing.result_metadata = trMeta;
351
+ existing.result_timestamp = tr.timestamp;
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+ let toolNum = 0;
358
+ for (const [, td] of toolCallMap) {
359
+ toolNum++;
360
+ const toolTs = String(td.result_timestamp ?? td.timestamp ?? timestampStr);
361
+ const toolStart = String(td.timestamp ?? startTimeStr);
362
+ const toolLat = latencySeconds(toolStart, toolTs);
363
+ const durationMs = td.result_metadata?.duration_ms;
364
+ spans.push({
365
+ trace_unique_id: traceUniqueId,
366
+ span_unique_id: `claudecode_${traceUniqueId}_tool_${toolNum}`,
367
+ span_parent_id: rootSpanId,
368
+ span_name: `Tool: ${td.name}`,
369
+ span_workflow_name: workflowName,
370
+ span_path: `tool_${String(td.name).toLowerCase()}`,
371
+ provider_id: '',
372
+ metadata: td.result_metadata ?? {},
373
+ input: formatToolInput(String(td.name), td.input),
374
+ output: formatToolOutput(String(td.name), td.output),
375
+ timestamp: toolTs,
376
+ start_time: toolStart,
377
+ ...(durationMs ? { latency: Number(durationMs) / 1000 } : toolLat !== undefined ? { latency: toolLat } : {}),
378
+ });
379
+ }
380
+ return addDefaultsToAll(spans);
381
+ }
382
+ // ── Transcript processing ─────────────────────────────────────────
383
+ function processTranscript(sessionId, transcriptFile, state, apiKey, baseUrl, config) {
384
+ const sessionState = (state[sessionId] ?? {});
385
+ const lastLine = Number(sessionState.last_line ?? 0);
386
+ const turnCount = Number(sessionState.turn_count ?? 0);
387
+ const content = fs.readFileSync(transcriptFile, 'utf-8');
388
+ const lines = content.trim().split('\n');
389
+ const totalLines = lines.length;
390
+ if (lastLine >= totalLines) {
391
+ debug(`No new lines to process (last: ${lastLine}, total: ${totalLines})`);
392
+ return { turnsProcessed: 0, lastCommittedLine: lastLine };
393
+ }
394
+ const newMessages = [];
395
+ for (let i = lastLine; i < totalLines; i++) {
396
+ try {
397
+ if (lines[i].trim()) {
398
+ const msg = JSON.parse(lines[i]);
399
+ newMessages.push({ ...msg, _lineIdx: i });
400
+ }
401
+ }
402
+ catch { }
403
+ }
404
+ if (newMessages.length === 0)
405
+ return { turnsProcessed: 0, lastCommittedLine: lastLine };
406
+ debug(`Processing ${newMessages.length} new messages`);
407
+ // Group into turns
408
+ let turnsProcessed = 0;
409
+ let lastCommittedLine = lastLine;
410
+ let currentUser = null;
411
+ let currentUserLine = lastLine;
412
+ let currentAssistants = [];
413
+ let currentAssistantParts = [];
414
+ let currentMsgId = null;
415
+ let currentToolResults = [];
416
+ const commitTurn = () => {
417
+ turnsProcessed++;
418
+ const turnNum = turnCount + turnsProcessed;
419
+ const spans = createSpans(sessionId, turnNum, currentUser, currentAssistants, currentToolResults, config);
420
+ sendSpans(spans, apiKey, baseUrl, `turn_${turnNum}`);
421
+ lastCommittedLine = totalLines;
422
+ };
423
+ for (const msg of newMessages) {
424
+ const lineIdx = msg._lineIdx;
425
+ delete msg._lineIdx;
426
+ const role = String(msg.type ?? msg.message?.role ?? '');
427
+ if (role === 'user') {
428
+ if (isToolResult(msg)) {
429
+ currentToolResults.push(msg);
430
+ continue;
431
+ }
432
+ // New user message — finalize previous turn
433
+ if (currentMsgId && currentAssistantParts.length) {
434
+ currentAssistants.push(mergeAssistantParts(currentAssistantParts));
435
+ currentAssistantParts = [];
436
+ currentMsgId = null;
437
+ }
438
+ if (currentUser && currentAssistants.length) {
439
+ commitTurn();
440
+ lastCommittedLine = lineIdx;
441
+ }
442
+ currentUser = msg;
443
+ currentUserLine = lineIdx;
444
+ currentAssistants = [];
445
+ currentAssistantParts = [];
446
+ currentMsgId = null;
447
+ currentToolResults = [];
448
+ }
449
+ else if (role === 'assistant') {
450
+ let msgId = null;
451
+ if (typeof msg === 'object' && msg.message) {
452
+ msgId = String(msg.message.id ?? '') || null;
453
+ }
454
+ if (!msgId) {
455
+ currentAssistantParts.push(msg);
456
+ }
457
+ else if (msgId === currentMsgId) {
458
+ currentAssistantParts.push(msg);
459
+ }
460
+ else {
461
+ if (currentMsgId && currentAssistantParts.length) {
462
+ currentAssistants.push(mergeAssistantParts(currentAssistantParts));
463
+ }
464
+ currentMsgId = msgId;
465
+ currentAssistantParts = [msg];
466
+ }
467
+ }
468
+ }
469
+ // Process final turn
470
+ if (currentMsgId && currentAssistantParts.length) {
471
+ currentAssistants.push(mergeAssistantParts(currentAssistantParts));
472
+ }
473
+ if (currentUser && currentAssistants.length) {
474
+ const hasText = currentAssistants.some((m) => getTextContent(m));
475
+ if (hasText) {
476
+ commitTurn();
477
+ lastCommittedLine = totalLines;
478
+ }
479
+ else {
480
+ lastCommittedLine = currentUserLine;
481
+ debug('Turn has assistant msgs but no text output yet, will retry');
482
+ }
483
+ }
484
+ else {
485
+ if (currentUser) {
486
+ lastCommittedLine = currentUserLine;
487
+ debug(`Incomplete turn at line ${currentUserLine}, will retry next run`);
488
+ }
489
+ else if (lastCommittedLine === lastLine) {
490
+ lastCommittedLine = totalLines;
491
+ }
492
+ }
493
+ return { turnsProcessed, lastCommittedLine };
494
+ }
495
+ // ── Stdin payload ─────────────────────────────────────────────────
496
+ function readStdinPayload() {
497
+ if (process.stdin.isTTY)
498
+ return null;
499
+ try {
500
+ const raw = fs.readFileSync(0, 'utf-8');
501
+ if (!raw.trim())
502
+ return null;
503
+ const payload = JSON.parse(raw);
504
+ const sessionId = String(payload.session_id ?? '');
505
+ const transcriptPath = String(payload.transcript_path ?? '');
506
+ if (!sessionId || !transcriptPath)
507
+ return null;
508
+ if (!fs.existsSync(transcriptPath))
509
+ return null;
510
+ debug(`Got transcript from stdin: session=${sessionId}, path=${transcriptPath}`);
511
+ return { sessionId, transcriptPath };
512
+ }
513
+ catch {
514
+ return null;
515
+ }
516
+ }
517
+ function findLatestTranscript() {
518
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
519
+ if (!fs.existsSync(projectsDir))
520
+ return null;
521
+ let latestFile = null;
522
+ let latestMtime = 0;
523
+ for (const projEntry of fs.readdirSync(projectsDir)) {
524
+ const projDir = path.join(projectsDir, projEntry);
525
+ if (!fs.statSync(projDir).isDirectory())
526
+ continue;
527
+ for (const file of fs.readdirSync(projDir)) {
528
+ if (!file.endsWith('.jsonl'))
529
+ continue;
530
+ const full = path.join(projDir, file);
531
+ const mtime = fs.statSync(full).mtimeMs;
532
+ if (mtime > latestMtime) {
533
+ latestMtime = mtime;
534
+ latestFile = full;
535
+ }
536
+ }
537
+ }
538
+ if (!latestFile)
539
+ return null;
540
+ try {
541
+ const firstLine = fs.readFileSync(latestFile, 'utf-8').split('\n')[0];
542
+ if (!firstLine)
543
+ return null;
544
+ const firstMsg = JSON.parse(firstLine);
545
+ const sessionId = String(firstMsg.sessionId ?? path.basename(latestFile, '.jsonl'));
546
+ return { sessionId, transcriptPath: latestFile };
547
+ }
548
+ catch {
549
+ return null;
550
+ }
551
+ }
552
+ // ── Main ──────────────────────────────────────────────────────────
553
+ async function main() {
554
+ const scriptStart = Date.now();
555
+ debug('Hook started');
556
+ if ((process.env.TRACE_TO_RESPAN ?? '').toLowerCase() !== 'true') {
557
+ debug('Tracing disabled (TRACE_TO_RESPAN != true)');
558
+ process.exit(0);
559
+ }
560
+ const creds = resolveCredentials();
561
+ if (!creds) {
562
+ log('ERROR', 'No API key found. Run: respan auth login');
563
+ process.exit(0);
564
+ }
565
+ // Find transcript
566
+ const payload = readStdinPayload() ?? findLatestTranscript();
567
+ if (!payload) {
568
+ debug('No transcript file found');
569
+ process.exit(0);
570
+ }
571
+ const { sessionId, transcriptPath } = payload;
572
+ debug(`Processing session: ${sessionId}`);
573
+ // Load respan.json config from project directory
574
+ let config = null;
575
+ try {
576
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
577
+ const lines = content.split('\n');
578
+ let cwd = '';
579
+ for (const line of lines.slice(0, 5)) {
580
+ if (!line.trim())
581
+ continue;
582
+ try {
583
+ const msg = JSON.parse(line);
584
+ if (msg.cwd) {
585
+ cwd = String(msg.cwd);
586
+ break;
587
+ }
588
+ }
589
+ catch { }
590
+ }
591
+ if (cwd) {
592
+ config = loadRespanConfig(path.join(cwd, '.claude', 'respan.json'));
593
+ debug(`Loaded respan.json config from ${cwd}`);
594
+ }
595
+ }
596
+ catch (e) {
597
+ debug(`Failed to load config: ${e}`);
598
+ }
599
+ // Process with retry
600
+ const maxAttempts = 3;
601
+ let turns = 0;
602
+ try {
603
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
604
+ const unlock = acquireLock(LOCK_PATH);
605
+ try {
606
+ const state = loadState(STATE_FILE);
607
+ const result = processTranscript(sessionId, transcriptPath, state, creds.apiKey, creds.baseUrl, config);
608
+ turns = result.turnsProcessed;
609
+ state[sessionId] = {
610
+ last_line: result.lastCommittedLine,
611
+ turn_count: (Number(state[sessionId]?.turn_count ?? 0)) + turns,
612
+ updated: nowISO(),
613
+ };
614
+ saveState(STATE_FILE, state);
615
+ }
616
+ finally {
617
+ unlock?.();
618
+ }
619
+ if (turns > 0)
620
+ break;
621
+ if (attempt < maxAttempts - 1) {
622
+ const delay = 500 * (attempt + 1);
623
+ debug(`No turns processed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay}ms...`);
624
+ await new Promise((r) => setTimeout(r, delay));
625
+ }
626
+ }
627
+ const duration = (Date.now() - scriptStart) / 1000;
628
+ log('INFO', `Processed ${turns} turns in ${duration.toFixed(1)}s`);
629
+ if (duration > 180)
630
+ log('WARN', `Hook took ${duration.toFixed(1)}s (>3min)`);
631
+ }
632
+ catch (e) {
633
+ log('ERROR', `Failed to process transcript: ${e}`);
634
+ if (DEBUG_MODE)
635
+ debug(String(e.stack ?? e));
636
+ }
637
+ }
638
+ main().catch((e) => {
639
+ log('ERROR', `Hook crashed: ${e}`);
640
+ process.exit(1);
641
+ });