@respan/cli 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  var fs2 = __toESM(require("node:fs"), 1);
27
27
  var os2 = __toESM(require("node:os"), 1);
28
28
  var path2 = __toESM(require("node:path"), 1);
29
+ var import_node_child_process = require("node:child_process");
29
30
 
30
31
  // src/hooks/shared.ts
31
32
  var fs = __toESM(require("node:fs"), 1);
@@ -870,24 +871,16 @@ function findLatestTranscript() {
870
871
  return null;
871
872
  }
872
873
  }
873
- async function main() {
874
+ async function mainWorker() {
874
875
  const scriptStart = Date.now();
875
- debug("Hook started");
876
- if ((process.env.TRACE_TO_RESPAN ?? "").toLowerCase() !== "true") {
877
- debug("Tracing disabled (TRACE_TO_RESPAN != true)");
878
- process.exit(0);
879
- }
876
+ debug("Worker started");
880
877
  const creds = resolveCredentials();
881
878
  if (!creds) {
882
879
  log("ERROR", "No API key found. Run: respan auth login");
883
- process.exit(0);
884
- }
885
- const payload = readStdinPayload() ?? findLatestTranscript();
886
- if (!payload) {
887
- debug("No transcript file found");
888
- process.exit(0);
880
+ return;
889
881
  }
890
- const { sessionId, transcriptPath } = payload;
882
+ const sessionId = process.env._RESPAN_SESSION_ID;
883
+ const transcriptPath = process.env._RESPAN_TRANSCRIPT_PATH;
891
884
  debug(`Processing session: ${sessionId}`);
892
885
  let config = null;
893
886
  try {
@@ -907,10 +900,8 @@ async function main() {
907
900
  }
908
901
  if (cwd) {
909
902
  config = loadRespanConfig(path2.join(cwd, ".claude", "respan.json"));
910
- debug(`Loaded respan.json config from ${cwd}`);
911
903
  }
912
- } catch (e) {
913
- debug(`Failed to load config: ${e}`);
904
+ } catch {
914
905
  }
915
906
  const maxAttempts = 3;
916
907
  let turns = 0;
@@ -932,20 +923,48 @@ async function main() {
932
923
  }
933
924
  if (turns > 0) break;
934
925
  if (attempt < maxAttempts - 1) {
935
- const delay = 500 * (attempt + 1);
936
- debug(`No turns processed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay}ms...`);
937
- await new Promise((r) => setTimeout(r, delay));
926
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
938
927
  }
939
928
  }
940
929
  const duration = (Date.now() - scriptStart) / 1e3;
941
930
  log("INFO", `Processed ${turns} turns in ${duration.toFixed(1)}s`);
942
- if (duration > 180) log("WARN", `Hook took ${duration.toFixed(1)}s (>3min)`);
943
931
  } catch (e) {
944
932
  log("ERROR", `Failed to process transcript: ${e}`);
945
- if (DEBUG_MODE) debug(String(e.stack ?? e));
946
933
  }
947
934
  }
948
- main().catch((e) => {
949
- log("ERROR", `Hook crashed: ${e}`);
950
- process.exit(1);
951
- });
935
+ function main() {
936
+ if (process.env._RESPAN_WORKER === "1") {
937
+ mainWorker().catch((e) => log("ERROR", `Worker crashed: ${e}`));
938
+ return;
939
+ }
940
+ debug("Hook started");
941
+ if ((process.env.TRACE_TO_RESPAN ?? "").toLowerCase() !== "true") {
942
+ debug("Tracing disabled (TRACE_TO_RESPAN != true)");
943
+ process.exit(0);
944
+ }
945
+ const payload = readStdinPayload() ?? findLatestTranscript();
946
+ if (!payload) {
947
+ debug("No transcript file found");
948
+ process.exit(0);
949
+ }
950
+ const { sessionId, transcriptPath } = payload;
951
+ debug(`Forking worker for session: ${sessionId}`);
952
+ try {
953
+ const scriptPath = __filename || process.argv[1];
954
+ const child = (0, import_node_child_process.execFile)("node", [scriptPath], {
955
+ env: {
956
+ ...process.env,
957
+ _RESPAN_WORKER: "1",
958
+ _RESPAN_SESSION_ID: sessionId,
959
+ _RESPAN_TRANSCRIPT_PATH: transcriptPath
960
+ },
961
+ stdio: "ignore",
962
+ detached: true
963
+ });
964
+ child.unref();
965
+ debug("Worker launched");
966
+ } catch (e) {
967
+ log("ERROR", `Failed to fork worker: ${e}`);
968
+ }
969
+ }
970
+ main();
@@ -14,6 +14,7 @@
14
14
  import * as fs from 'node:fs';
15
15
  import * as os from 'node:os';
16
16
  import * as path from 'node:path';
17
+ import { execFile } from 'node:child_process';
17
18
  import { initLogging, log, debug, resolveCredentials, loadRespanConfig, loadState, saveState, acquireLock, sendSpans, addDefaultsToAll, resolveSpanFields, buildMetadata, nowISO, latencySeconds, truncate, } from './shared.js';
18
19
  // ── Config ────────────────────────────────────────────────────────
19
20
  const STATE_DIR = path.join(os.homedir(), '.claude', 'state');
@@ -550,27 +551,17 @@ function findLatestTranscript() {
550
551
  }
551
552
  }
552
553
  // ── Main ──────────────────────────────────────────────────────────
553
- async function main() {
554
+ async function mainWorker() {
554
555
  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
- }
556
+ debug('Worker started');
560
557
  const creds = resolveCredentials();
561
558
  if (!creds) {
562
559
  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);
560
+ return;
570
561
  }
571
- const { sessionId, transcriptPath } = payload;
562
+ const sessionId = process.env._RESPAN_SESSION_ID;
563
+ const transcriptPath = process.env._RESPAN_TRANSCRIPT_PATH;
572
564
  debug(`Processing session: ${sessionId}`);
573
- // Load respan.json config from project directory
574
565
  let config = null;
575
566
  try {
576
567
  const content = fs.readFileSync(transcriptPath, 'utf-8');
@@ -590,13 +581,9 @@ async function main() {
590
581
  }
591
582
  if (cwd) {
592
583
  config = loadRespanConfig(path.join(cwd, '.claude', 'respan.json'));
593
- debug(`Loaded respan.json config from ${cwd}`);
594
584
  }
595
585
  }
596
- catch (e) {
597
- debug(`Failed to load config: ${e}`);
598
- }
599
- // Process with retry
586
+ catch { }
600
587
  const maxAttempts = 3;
601
588
  let turns = 0;
602
589
  try {
@@ -619,23 +606,52 @@ async function main() {
619
606
  if (turns > 0)
620
607
  break;
621
608
  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));
609
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
625
610
  }
626
611
  }
627
612
  const duration = (Date.now() - scriptStart) / 1000;
628
613
  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
614
  }
632
615
  catch (e) {
633
616
  log('ERROR', `Failed to process transcript: ${e}`);
634
- if (DEBUG_MODE)
635
- debug(String(e.stack ?? e));
636
617
  }
637
618
  }
638
- main().catch((e) => {
639
- log('ERROR', `Hook crashed: ${e}`);
640
- process.exit(1);
641
- });
619
+ function main() {
620
+ // Worker mode: re-invoked as detached subprocess
621
+ if (process.env._RESPAN_WORKER === '1') {
622
+ mainWorker().catch((e) => log('ERROR', `Worker crashed: ${e}`));
623
+ return;
624
+ }
625
+ debug('Hook started');
626
+ if ((process.env.TRACE_TO_RESPAN ?? '').toLowerCase() !== 'true') {
627
+ debug('Tracing disabled (TRACE_TO_RESPAN != true)');
628
+ process.exit(0);
629
+ }
630
+ const payload = readStdinPayload() ?? findLatestTranscript();
631
+ if (!payload) {
632
+ debug('No transcript file found');
633
+ process.exit(0);
634
+ }
635
+ // Fork self as detached worker so Claude Code doesn't block
636
+ const { sessionId, transcriptPath } = payload;
637
+ debug(`Forking worker for session: ${sessionId}`);
638
+ try {
639
+ const scriptPath = __filename || process.argv[1];
640
+ const child = execFile('node', [scriptPath], {
641
+ env: {
642
+ ...process.env,
643
+ _RESPAN_WORKER: '1',
644
+ _RESPAN_SESSION_ID: sessionId,
645
+ _RESPAN_TRANSCRIPT_PATH: transcriptPath,
646
+ },
647
+ stdio: 'ignore',
648
+ detached: true,
649
+ });
650
+ child.unref();
651
+ debug('Worker launched');
652
+ }
653
+ catch (e) {
654
+ log('ERROR', `Failed to fork worker: ${e}`);
655
+ }
656
+ }
657
+ main();
@@ -665,7 +665,6 @@ function processBeforeTool(hookData) {
665
665
  pending.push({ name: toolName, input: toolInput, start_time: nowISO() });
666
666
  state.pending_tools = pending;
667
667
  saveStreamState(sessionId, state);
668
- process.stdout.write("{}\n");
669
668
  }
670
669
  function processAfterTool(hookData) {
671
670
  const sessionId = String(hookData.session_id ?? "unknown");
@@ -690,7 +689,6 @@ function processAfterTool(hookData) {
690
689
  state.pending_tools = pending;
691
690
  state.tool_details = completed;
692
691
  saveStreamState(sessionId, state);
693
- process.stdout.write("{}\n");
694
692
  }
695
693
  function processChunk(hookData) {
696
694
  const sessionId = String(hookData.session_id ?? "unknown");
@@ -771,12 +769,12 @@ function processChunk(hookData) {
771
769
  }
772
770
  saveStreamState(sessionId, state);
773
771
  debug(`Tool call via response parts (finish=${finishReason}), tool_turns=${state.tool_turns}`);
774
- process.stdout.write("{}\n");
775
772
  return;
776
773
  }
774
+ const hasPendingTools = (state.pending_tools ?? []).length > 0;
775
+ const hadToolsThisTurn = (state.tool_turns ?? 0) > 0 || hasPendingTools;
777
776
  const hasNewText = state.accumulated_text.length > (state.last_send_text_len ?? 0);
778
- const shouldSend = (!toolCallDetected || isFinished) && hasNewText && state.accumulated_text && (!chunkText || isFinished);
779
- process.stdout.write("{}\n");
777
+ const shouldSend = hasNewText && state.accumulated_text && (isFinished || !hadToolsThisTurn && !toolCallDetected && !chunkText);
780
778
  if (!shouldSend) {
781
779
  if (toolCallDetected) saveStreamState(sessionId, state);
782
780
  return;
@@ -817,10 +815,8 @@ function processChunk(hookData) {
817
815
  function main() {
818
816
  try {
819
817
  const raw = fs2.readFileSync(0, "utf-8");
820
- if (!raw.trim()) {
821
- process.stdout.write("{}\n");
822
- return;
823
- }
818
+ process.stdout.write("{}\n");
819
+ if (!raw.trim()) return;
824
820
  const hookData = JSON.parse(raw);
825
821
  const event = String(hookData.hook_event_name ?? "");
826
822
  const unlock = acquireLock(LOCK_PATH);
@@ -841,7 +837,6 @@ function main() {
841
837
  } else {
842
838
  log("ERROR", `Hook error: ${e}`);
843
839
  }
844
- process.stdout.write("{}\n");
845
840
  }
846
841
  }
847
842
  main();
@@ -388,7 +388,6 @@ function processBeforeTool(hookData) {
388
388
  pending.push({ name: toolName, input: toolInput, start_time: nowISO() });
389
389
  state.pending_tools = pending;
390
390
  saveStreamState(sessionId, state);
391
- process.stdout.write('{}\n');
392
391
  }
393
392
  function processAfterTool(hookData) {
394
393
  const sessionId = String(hookData.session_id ?? 'unknown');
@@ -415,7 +414,6 @@ function processAfterTool(hookData) {
415
414
  state.pending_tools = pending;
416
415
  state.tool_details = completed;
417
416
  saveStreamState(sessionId, state);
418
- process.stdout.write('{}\n');
419
417
  }
420
418
  // ── AfterModel chunk processing ──────────────────────────────────
421
419
  function processChunk(hookData) {
@@ -507,16 +505,18 @@ function processChunk(hookData) {
507
505
  }
508
506
  saveStreamState(sessionId, state);
509
507
  debug(`Tool call via response parts (finish=${finishReason}), tool_turns=${state.tool_turns}`);
510
- process.stdout.write('{}\n');
511
508
  return;
512
509
  }
513
- // Detect completion and send
510
+ // Detect completion and send.
511
+ // If tools have been used this turn, only send on STOP to avoid
512
+ // splitting a multi-tool turn into separate traces.
513
+ const hasPendingTools = (state.pending_tools ?? []).length > 0;
514
+ const hadToolsThisTurn = (state.tool_turns ?? 0) > 0 || hasPendingTools;
514
515
  const hasNewText = state.accumulated_text.length > (state.last_send_text_len ?? 0);
515
- const shouldSend = ((!toolCallDetected || isFinished)
516
- && hasNewText
516
+ const shouldSend = (hasNewText
517
517
  && state.accumulated_text
518
- && (!chunkText || isFinished));
519
- process.stdout.write('{}\n');
518
+ && (isFinished
519
+ || (!hadToolsThisTurn && !toolCallDetected && !chunkText)));
520
520
  if (!shouldSend) {
521
521
  if (toolCallDetected)
522
522
  saveStreamState(sessionId, state);
@@ -552,10 +552,10 @@ function processChunk(hookData) {
552
552
  function main() {
553
553
  try {
554
554
  const raw = fs.readFileSync(0, 'utf-8');
555
- if (!raw.trim()) {
556
- process.stdout.write('{}\n');
555
+ // Respond to Gemini CLI immediately so it doesn't block
556
+ process.stdout.write('{}\n');
557
+ if (!raw.trim())
557
558
  return;
558
- }
559
559
  const hookData = JSON.parse(raw);
560
560
  const event = String(hookData.hook_event_name ?? '');
561
561
  const unlock = acquireLock(LOCK_PATH);
@@ -581,7 +581,6 @@ function main() {
581
581
  else {
582
582
  log('ERROR', `Hook error: ${e}`);
583
583
  }
584
- process.stdout.write('{}\n');
585
584
  }
586
585
  }
587
586
  main();