@respan/cli 0.7.0 → 0.7.2

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.
@@ -5,6 +5,8 @@ export default class IntegrateClaudeCode extends BaseCommand {
5
5
  static flags: {
6
6
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ enable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
10
  'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
11
  'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
12
  attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -7,6 +7,37 @@ class IntegrateClaudeCode extends BaseCommand {
7
7
  const { flags } = await this.parse(IntegrateClaudeCode);
8
8
  this.globalFlags = flags;
9
9
  try {
10
+ const dryRun = flags['dry-run'];
11
+ const scope = resolveScope(flags, 'both');
12
+ const doLocal = scope === 'local' || scope === 'both';
13
+ // ── Disable mode ─────────────────────────────────────────────
14
+ if (flags.disable) {
15
+ // Remove the Stop hook entry from global settings
16
+ const globalSettingsPath = expandHome('~/.claude/settings.json');
17
+ const globalSettings = readJsonFile(globalSettingsPath);
18
+ const hooksSection = (globalSettings.hooks || {});
19
+ if (Array.isArray(hooksSection.Stop)) {
20
+ hooksSection.Stop = hooksSection.Stop.filter((entry) => {
21
+ const inner = Array.isArray(entry.hooks)
22
+ ? entry.hooks
23
+ : [];
24
+ return !inner.some((h) => typeof h.command === 'string' &&
25
+ (h.command.includes('respan') || h.command.includes('hook.py') || h.command.includes('claude-code')));
26
+ });
27
+ }
28
+ const merged = deepMerge(globalSettings, { hooks: hooksSection });
29
+ if (dryRun) {
30
+ this.log(`[dry-run] Would update: ${globalSettingsPath}`);
31
+ this.log(JSON.stringify(merged, null, 2));
32
+ }
33
+ else {
34
+ writeJsonFile(globalSettingsPath, merged);
35
+ this.log(`Removed hook entry: ${globalSettingsPath}`);
36
+ }
37
+ this.log('Claude Code tracing disabled. Run "respan integrate claude-code" to re-enable.');
38
+ return;
39
+ }
40
+ // ── Enable mode (default) ────────────────────────────────────
10
41
  // Verify the user is authenticated (key is read by hook from ~/.respan/)
11
42
  this.resolveApiKey();
12
43
  const baseUrl = flags['base-url'];
@@ -15,11 +46,8 @@ class IntegrateClaudeCode extends BaseCommand {
15
46
  const spanName = flags['span-name'];
16
47
  const workflowName = flags['workflow-name'];
17
48
  const attrs = parseAttrs(flags.attrs);
18
- const dryRun = flags['dry-run'];
19
- // Claude Code default: both global + local
20
- const scope = resolveScope(flags, 'both');
49
+ // Claude Code default: both global + local.
21
50
  const doGlobal = scope === 'global' || scope === 'both';
22
- const doLocal = scope === 'local' || scope === 'both';
23
51
  // ── Global: hook script + registration ────────────────────────
24
52
  if (doGlobal) {
25
53
  // 1. Write JS hook script (no Python dependency needed)
@@ -165,9 +193,11 @@ them to Respan as structured spans (chat, tool, thinking).
165
193
  Scope:
166
194
  --global Install hook script + register in ~/.claude/settings.json
167
195
  --local Write credentials + enable flag to .claude/settings.local.json
168
- (default) Both: install hook globally + enable for current project`;
196
+
197
+ Default behavior installs the global hook and writes local project config.`;
169
198
  IntegrateClaudeCode.examples = [
170
199
  'respan integrate claude-code',
200
+ 'respan integrate claude-code --disable',
171
201
  'respan integrate claude-code --global',
172
202
  'respan integrate claude-code --local --project-id my-project',
173
203
  'respan integrate claude-code --attrs \'{"env":"prod"}\'',
@@ -5,6 +5,8 @@ export default class IntegrateCodexCli extends BaseCommand {
5
5
  static flags: {
6
6
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ enable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
10
  'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
11
  'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
12
  attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -6,6 +6,26 @@ class IntegrateCodexCli extends BaseCommand {
6
6
  const { flags } = await this.parse(IntegrateCodexCli);
7
7
  this.globalFlags = flags;
8
8
  try {
9
+ const dryRun = flags['dry-run'];
10
+ // ── Disable mode ─────────────────────────────────────────────
11
+ if (flags.disable) {
12
+ // Remove the notify line from ~/.codex/config.toml
13
+ const configPath = expandHome('~/.codex/config.toml');
14
+ const existing = readTextFile(configPath);
15
+ const lines = existing.split('\n');
16
+ const filtered = lines.filter((line) => !/^\s*notify\s*=/.test(line) && !line.includes('respan integrate codex-cli'));
17
+ if (dryRun) {
18
+ this.log(`[dry-run] Would update: ${configPath}`);
19
+ this.log(filtered.join('\n'));
20
+ }
21
+ else {
22
+ writeTextFile(configPath, filtered.join('\n'));
23
+ this.log(`Removed notify hook: ${configPath}`);
24
+ }
25
+ this.log('Codex CLI tracing disabled. Run "respan integrate codex-cli" to re-enable.');
26
+ return;
27
+ }
28
+ // ── Enable mode (default) ────────────────────────────────────
9
29
  // Verify the user is authenticated (key is read by hook from ~/.respan/)
10
30
  this.resolveApiKey();
11
31
  const projectId = flags['project-id'];
@@ -13,7 +33,6 @@ class IntegrateCodexCli extends BaseCommand {
13
33
  const spanName = flags['span-name'];
14
34
  const workflowName = flags['workflow-name'];
15
35
  const attrs = parseAttrs(flags.attrs);
16
- const dryRun = flags['dry-run'];
17
36
  // Codex CLI default: both global + local
18
37
  const scope = resolveScope(flags, 'both');
19
38
  const doGlobal = scope === 'global' || scope === 'both';
@@ -155,9 +174,11 @@ them to Respan as structured spans (chat, tool, reasoning).
155
174
  Scope:
156
175
  --global Install hook script + register notify in ~/.codex/config.toml
157
176
  --local Write .codex/respan.json with customer_id, span_name, etc.
158
- (default) Both: install hook globally + config for current project`;
177
+
178
+ Default behavior installs the global notify hook and writes local project config.`;
159
179
  IntegrateCodexCli.examples = [
160
180
  'respan integrate codex-cli',
181
+ 'respan integrate codex-cli --disable',
161
182
  'respan integrate codex-cli --global',
162
183
  'respan integrate codex-cli --local --customer-id frank',
163
184
  'respan integrate codex-cli --attrs \'{"env":"prod"}\'',
@@ -5,6 +5,8 @@ export default class IntegrateGeminiCli extends BaseCommand {
5
5
  static flags: {
6
6
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ enable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
10
  'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
11
  'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
12
  attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -7,6 +7,40 @@ class IntegrateGeminiCli extends BaseCommand {
7
7
  const { flags } = await this.parse(IntegrateGeminiCli);
8
8
  this.globalFlags = flags;
9
9
  try {
10
+ const dryRun = flags['dry-run'];
11
+ const scope = resolveScope(flags, 'global');
12
+ // ── Disable mode ─────────────────────────────────────────────
13
+ if (flags.disable) {
14
+ // Remove respan hook entries from settings.json
15
+ const settingsPath = scope === 'global'
16
+ ? expandHome('~/.gemini/settings.json')
17
+ : path.join(findProjectRoot(), '.gemini', 'settings.json');
18
+ const existing = readJsonFile(settingsPath);
19
+ const hooksSection = (existing.hooks || {});
20
+ for (const eventName of ['AfterModel', 'BeforeTool', 'AfterTool']) {
21
+ if (!Array.isArray(hooksSection[eventName]))
22
+ continue;
23
+ hooksSection[eventName] = hooksSection[eventName].filter((entry) => {
24
+ const inner = Array.isArray(entry.hooks)
25
+ ? entry.hooks
26
+ : [];
27
+ return !inner.some((h) => typeof h.command === 'string' &&
28
+ (h.command.includes('respan') || h.command.includes('gemini-cli')));
29
+ });
30
+ }
31
+ const merged = { ...existing, hooks: hooksSection };
32
+ if (dryRun) {
33
+ this.log(`[dry-run] Would update: ${settingsPath}`);
34
+ this.log(JSON.stringify(merged, null, 2));
35
+ }
36
+ else {
37
+ writeJsonFile(settingsPath, merged);
38
+ this.log(`Removed hook entries: ${settingsPath}`);
39
+ }
40
+ this.log('Gemini CLI tracing disabled. Run "respan integrate gemini-cli" to re-enable.');
41
+ return;
42
+ }
43
+ // ── Enable mode (default) ────────────────────────────────────
10
44
  // Verify the user is authenticated (key is read by hook from ~/.respan/)
11
45
  this.resolveApiKey();
12
46
  const projectId = flags['project-id'];
@@ -14,8 +48,6 @@ class IntegrateGeminiCli extends BaseCommand {
14
48
  const spanName = flags['span-name'];
15
49
  const workflowName = flags['workflow-name'];
16
50
  const attrs = parseAttrs(flags.attrs);
17
- const dryRun = flags['dry-run'];
18
- const scope = resolveScope(flags, 'global');
19
51
  // ── 1. Install hook script ──────────────────────────────────
20
52
  const hookDir = expandHome('~/.respan/hooks');
21
53
  const hookPath = `${hookDir}/gemini-cli.cjs`;
@@ -142,10 +174,10 @@ Scope:
142
174
  --global Write to ~/.gemini/settings.json (default)
143
175
  --local Write to .gemini/settings.json in project root
144
176
 
145
- Note: Gemini CLI ignores workspace-level telemetry settings, so
146
- --global is the default.`;
177
+ Gemini CLI ignores workspace-level telemetry settings, so --global is the default.`;
147
178
  IntegrateGeminiCli.examples = [
148
179
  'respan integrate gemini-cli',
180
+ 'respan integrate gemini-cli --disable',
149
181
  'respan integrate gemini-cli --local',
150
182
  'respan integrate gemini-cli --project-id my-project --attrs \'{"env":"prod"}\'',
151
183
  'respan integrate gemini-cli --dry-run',
@@ -5,6 +5,8 @@ export default class IntegrateOpencode extends BaseCommand {
5
5
  static flags: {
6
6
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ enable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
10
  'project-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
11
  'base-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
12
  attrs: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,3 +1,4 @@
1
+ import * as fs from 'node:fs';
1
2
  import * as path from 'node:path';
2
3
  import { execSync } from 'node:child_process';
3
4
  import { BaseCommand } from '../../lib/base-command.js';
@@ -7,12 +8,33 @@ class IntegrateOpencode extends BaseCommand {
7
8
  const { flags } = await this.parse(IntegrateOpencode);
8
9
  this.globalFlags = flags;
9
10
  try {
11
+ const dryRun = flags['dry-run'];
12
+ const scope = resolveScope(flags, 'local');
13
+ // ── Disable mode ─────────────────────────────────────────────
14
+ if (flags.disable) {
15
+ const pluginPath = scope === 'global'
16
+ ? expandHome('~/.config/opencode/plugins/otel.json')
17
+ : path.join(findProjectRoot(), '.opencode', 'plugins', 'otel.json');
18
+ if (dryRun) {
19
+ this.log(`[dry-run] Would remove: ${pluginPath}`);
20
+ }
21
+ else {
22
+ try {
23
+ fs.unlinkSync(pluginPath);
24
+ this.log(`Removed plugin config: ${pluginPath}`);
25
+ }
26
+ catch {
27
+ this.log(`No plugin config found at: ${pluginPath}`);
28
+ }
29
+ }
30
+ this.log('OpenCode tracing disabled. Run with --enable to re-enable.');
31
+ return;
32
+ }
33
+ // ── Enable mode (default) ────────────────────────────────────
10
34
  const apiKey = this.resolveApiKey();
11
35
  const baseUrl = (flags['base-url']).replace(/\/+$/, '');
12
36
  const projectId = flags['project-id'];
13
37
  const attrs = parseAttrs(flags.attrs);
14
- const dryRun = flags['dry-run'];
15
- const scope = resolveScope(flags, 'local');
16
38
  // ── 1. Install opencode-otel (always global) ─────────────────
17
39
  if (dryRun) {
18
40
  this.log('[dry-run] Would run: npm install -g opencode-otel');
@@ -89,6 +111,7 @@ Scope:
89
111
  The opencode-otel package is always installed globally.`;
90
112
  IntegrateOpencode.examples = [
91
113
  'respan integrate opencode',
114
+ 'respan integrate opencode --disable',
92
115
  'respan integrate opencode --global',
93
116
  'respan integrate opencode --project-id my-project --attrs \'{"env":"prod"}\'',
94
117
  'respan integrate opencode --dry-run',
@@ -280,6 +280,7 @@ function stringToSpanId(s) {
280
280
  function toOtlpPayload(spans) {
281
281
  const otlpSpans = spans.map((span) => {
282
282
  const attrs = {};
283
+ if (span.session_identifier) attrs["respan.sessions.session_identifier"] = span.session_identifier;
283
284
  if (span.thread_identifier) attrs["respan.threads.thread_identifier"] = span.thread_identifier;
284
285
  if (span.customer_identifier) attrs["respan.customer_params.customer_identifier"] = span.customer_identifier;
285
286
  if (span.span_workflow_name) attrs["traceloop.workflow.name"] = span.span_workflow_name;
@@ -340,7 +341,7 @@ function toOtlpPayload(spans) {
340
341
  })
341
342
  },
342
343
  scopeSpans: [{
343
- scope: { name: "respan-cli-hooks", version: "0.7.0" },
344
+ scope: { name: "respan-cli-hooks", version: "0.7.1" },
344
345
  spans: otlpSpans
345
346
  }]
346
347
  }]
@@ -599,6 +600,7 @@ function createSpans(sessionId, turnNum, userMsg, assistantMsgs, toolResults, co
599
600
  const rootSpanId = `claudecode_${traceUniqueId}_root`;
600
601
  spans.push({
601
602
  trace_unique_id: traceUniqueId,
603
+ session_identifier: threadId,
602
604
  thread_identifier: threadId,
603
605
  customer_identifier: customerId,
604
606
  span_unique_id: rootSpanId,
@@ -248,6 +248,7 @@ function createSpans(sessionId, turnNum, userMsg, assistantMsgs, toolResults, co
248
248
  const rootSpanId = `claudecode_${traceUniqueId}_root`;
249
249
  spans.push({
250
250
  trace_unique_id: traceUniqueId,
251
+ session_identifier: threadId,
251
252
  thread_identifier: threadId,
252
253
  customer_identifier: customerId,
253
254
  span_unique_id: rootSpanId,
@@ -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);
@@ -279,6 +280,7 @@ function stringToSpanId(s) {
279
280
  function toOtlpPayload(spans) {
280
281
  const otlpSpans = spans.map((span) => {
281
282
  const attrs = {};
283
+ if (span.session_identifier) attrs["respan.sessions.session_identifier"] = span.session_identifier;
282
284
  if (span.thread_identifier) attrs["respan.threads.thread_identifier"] = span.thread_identifier;
283
285
  if (span.customer_identifier) attrs["respan.customer_params.customer_identifier"] = span.customer_identifier;
284
286
  if (span.span_workflow_name) attrs["traceloop.workflow.name"] = span.span_workflow_name;
@@ -339,7 +341,7 @@ function toOtlpPayload(spans) {
339
341
  })
340
342
  },
341
343
  scopeSpans: [{
342
- scope: { name: "respan-cli-hooks", version: "0.7.0" },
344
+ scope: { name: "respan-cli-hooks", version: "0.7.1" },
343
345
  spans: otlpSpans
344
346
  }]
345
347
  }]
@@ -572,6 +574,7 @@ function createSpans(sessionId, turnNum, turn, config) {
572
574
  const rootSpanId = `codexcli_${traceUniqueId}_root`;
573
575
  spans.push({
574
576
  trace_unique_id: traceUniqueId,
577
+ session_identifier: threadId,
575
578
  thread_identifier: threadId,
576
579
  customer_identifier: customerId,
577
580
  span_unique_id: rootSpanId,
@@ -699,50 +702,18 @@ function findLatestSessionFile() {
699
702
  return null;
700
703
  }
701
704
  }
702
- async function main() {
705
+ async function mainWorker() {
703
706
  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}`);
707
+ debug("Worker started");
708
+ const sessionId = process.env._RESPAN_CODEX_SESSION;
709
+ const sessionFile = process.env._RESPAN_CODEX_FILE;
710
+ const cwd = process.env._RESPAN_CODEX_CWD ?? "";
727
711
  const creds = resolveCredentials();
728
712
  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
- }
713
+ log("ERROR", "No API key");
714
+ return;
742
715
  }
743
- const cwd = String(payload.cwd ?? "");
744
716
  const config = cwd ? loadRespanConfig(path2.join(cwd, ".codex", "respan.json")) : null;
745
- if (config) debug(`Loaded respan.json config from ${cwd}`);
746
717
  const maxAttempts = 3;
747
718
  let turns = 0;
748
719
  try {
@@ -774,20 +745,73 @@ async function main() {
774
745
  }
775
746
  if (turns > 0) break;
776
747
  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));
748
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
780
749
  }
781
750
  }
782
751
  const duration = (Date.now() - scriptStart) / 1e3;
783
752
  log("INFO", `Processed ${turns} turns in ${duration.toFixed(1)}s`);
784
- if (duration > 180) log("WARN", `Hook took ${duration.toFixed(1)}s (>3min)`);
785
753
  } catch (e) {
786
754
  log("ERROR", `Failed to process session: ${e}`);
787
- if (DEBUG_MODE) debug(String(e.stack ?? e));
788
755
  }
789
756
  }
790
- main().catch((e) => {
791
- log("ERROR", `Hook crashed: ${e}`);
792
- process.exit(1);
793
- });
757
+ function main() {
758
+ if (process.env._RESPAN_CODEX_WORKER === "1") {
759
+ mainWorker().catch((e) => log("ERROR", `Worker crashed: ${e}`));
760
+ return;
761
+ }
762
+ debug("Codex hook started");
763
+ if (process.argv.length < 3) {
764
+ debug("No argument provided");
765
+ process.exit(0);
766
+ }
767
+ let payload;
768
+ try {
769
+ payload = JSON.parse(process.argv[2]);
770
+ } catch (e) {
771
+ debug(`Invalid JSON in argv[2]: ${e}`);
772
+ process.exit(0);
773
+ }
774
+ const eventType = String(payload.type ?? "");
775
+ if (eventType !== "agent-turn-complete") {
776
+ debug(`Ignoring event type: ${eventType}`);
777
+ process.exit(0);
778
+ }
779
+ let sessionId = String(payload["thread-id"] ?? "");
780
+ if (!sessionId) {
781
+ debug("No thread-id in notify payload");
782
+ process.exit(0);
783
+ }
784
+ let sessionFile = findSessionFile(sessionId);
785
+ if (!sessionFile) {
786
+ const latest = findLatestSessionFile();
787
+ if (latest) {
788
+ sessionId = latest.sessionId;
789
+ sessionFile = latest.sessionFile;
790
+ } else {
791
+ debug("No session file found");
792
+ process.exit(0);
793
+ }
794
+ }
795
+ const cwd = String(payload.cwd ?? "");
796
+ debug(`Forking worker for session: ${sessionId}`);
797
+ try {
798
+ const scriptPath = __filename || process.argv[1];
799
+ const child = (0, import_node_child_process.execFile)("node", [scriptPath], {
800
+ env: {
801
+ ...process.env,
802
+ _RESPAN_CODEX_WORKER: "1",
803
+ _RESPAN_CODEX_SESSION: sessionId,
804
+ _RESPAN_CODEX_FILE: sessionFile,
805
+ _RESPAN_CODEX_CWD: cwd
806
+ },
807
+ stdio: "ignore",
808
+ detached: true
809
+ });
810
+ child.unref();
811
+ debug("Worker launched");
812
+ } catch (e) {
813
+ log("ERROR", `Failed to fork worker: ${e}`);
814
+ }
815
+ process.exit(0);
816
+ }
817
+ main();
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'node:fs';
17
17
  import * as os from 'node:os';
18
18
  import * as path from 'node:path';
19
+ import { execFile } from 'node:child_process';
19
20
  import { initLogging, log, debug, resolveCredentials, loadRespanConfig, loadState, saveState, acquireLock, sendSpans, addDefaultsToAll, resolveSpanFields, buildMetadata, nowISO, latencySeconds, truncate, } from './shared.js';
20
21
  // ── Config ────────────────────────────────────────────────────────
21
22
  const STATE_DIR = path.join(os.homedir(), '.codex', 'state');
@@ -221,6 +222,7 @@ function createSpans(sessionId, turnNum, turn, config) {
221
222
  const rootSpanId = `codexcli_${traceUniqueId}_root`;
222
223
  spans.push({
223
224
  trace_unique_id: traceUniqueId,
225
+ session_identifier: threadId,
224
226
  thread_identifier: threadId,
225
227
  customer_identifier: customerId,
226
228
  span_unique_id: rootSpanId,
@@ -363,57 +365,18 @@ function findLatestSessionFile() {
363
365
  }
364
366
  }
365
367
  // ── Main ──────────────────────────────────────────────────────────
366
- async function main() {
368
+ async function mainWorker() {
367
369
  const scriptStart = Date.now();
368
- debug('Codex hook started');
369
- // Parse notify payload from argv[2] (argv[0]=node, argv[1]=script)
370
- if (process.argv.length < 3) {
371
- debug('No argument provided (expected JSON payload in argv[2])');
372
- process.exit(0);
373
- }
374
- let payload;
375
- try {
376
- payload = JSON.parse(process.argv[2]);
377
- }
378
- catch (e) {
379
- debug(`Invalid JSON in argv[2]: ${e}`);
380
- process.exit(0);
381
- }
382
- const eventType = String(payload.type ?? '');
383
- if (eventType !== 'agent-turn-complete') {
384
- debug(`Ignoring event type: ${eventType}`);
385
- process.exit(0);
386
- }
387
- let sessionId = String(payload['thread-id'] ?? '');
388
- if (!sessionId) {
389
- debug('No thread-id in notify payload');
390
- process.exit(0);
391
- }
392
- debug(`Processing notify: type=${eventType}, session=${sessionId}`);
370
+ debug('Worker started');
371
+ const sessionId = process.env._RESPAN_CODEX_SESSION;
372
+ const sessionFile = process.env._RESPAN_CODEX_FILE;
373
+ const cwd = process.env._RESPAN_CODEX_CWD ?? '';
393
374
  const creds = resolveCredentials();
394
375
  if (!creds) {
395
- log('ERROR', 'No API key found. Run: respan auth login');
396
- process.exit(0);
397
- }
398
- // Find session file
399
- let sessionFile = findSessionFile(sessionId);
400
- if (!sessionFile) {
401
- const latest = findLatestSessionFile();
402
- if (latest) {
403
- sessionId = latest.sessionId;
404
- sessionFile = latest.sessionFile;
405
- }
406
- else {
407
- debug('No session file found');
408
- process.exit(0);
409
- }
376
+ log('ERROR', 'No API key');
377
+ return;
410
378
  }
411
- // Load config
412
- const cwd = String(payload.cwd ?? '');
413
379
  const config = cwd ? loadRespanConfig(path.join(cwd, '.codex', 'respan.json')) : null;
414
- if (config)
415
- debug(`Loaded respan.json config from ${cwd}`);
416
- // Process with retry
417
380
  const maxAttempts = 3;
418
381
  let turns = 0;
419
382
  try {
@@ -447,23 +410,80 @@ async function main() {
447
410
  if (turns > 0)
448
411
  break;
449
412
  if (attempt < maxAttempts - 1) {
450
- const delay = 500 * (attempt + 1);
451
- debug(`No turns processed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${delay}ms...`);
452
- await new Promise((r) => setTimeout(r, delay));
413
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
453
414
  }
454
415
  }
455
416
  const duration = (Date.now() - scriptStart) / 1000;
456
417
  log('INFO', `Processed ${turns} turns in ${duration.toFixed(1)}s`);
457
- if (duration > 180)
458
- log('WARN', `Hook took ${duration.toFixed(1)}s (>3min)`);
459
418
  }
460
419
  catch (e) {
461
420
  log('ERROR', `Failed to process session: ${e}`);
462
- if (DEBUG_MODE)
463
- debug(String(e.stack ?? e));
464
421
  }
465
422
  }
466
- main().catch((e) => {
467
- log('ERROR', `Hook crashed: ${e}`);
468
- process.exit(1);
469
- });
423
+ function main() {
424
+ // Worker mode: re-invoked as detached subprocess
425
+ if (process.env._RESPAN_CODEX_WORKER === '1') {
426
+ mainWorker().catch((e) => log('ERROR', `Worker crashed: ${e}`));
427
+ return;
428
+ }
429
+ debug('Codex hook started');
430
+ if (process.argv.length < 3) {
431
+ debug('No argument provided');
432
+ process.exit(0);
433
+ }
434
+ let payload;
435
+ try {
436
+ payload = JSON.parse(process.argv[2]);
437
+ }
438
+ catch (e) {
439
+ debug(`Invalid JSON in argv[2]: ${e}`);
440
+ process.exit(0);
441
+ }
442
+ const eventType = String(payload.type ?? '');
443
+ if (eventType !== 'agent-turn-complete') {
444
+ debug(`Ignoring event type: ${eventType}`);
445
+ process.exit(0);
446
+ }
447
+ let sessionId = String(payload['thread-id'] ?? '');
448
+ if (!sessionId) {
449
+ debug('No thread-id in notify payload');
450
+ process.exit(0);
451
+ }
452
+ // Find session file
453
+ let sessionFile = findSessionFile(sessionId);
454
+ if (!sessionFile) {
455
+ const latest = findLatestSessionFile();
456
+ if (latest) {
457
+ sessionId = latest.sessionId;
458
+ sessionFile = latest.sessionFile;
459
+ }
460
+ else {
461
+ debug('No session file found');
462
+ process.exit(0);
463
+ }
464
+ }
465
+ // Fork self as detached worker so Codex CLI doesn't block
466
+ const cwd = String(payload.cwd ?? '');
467
+ debug(`Forking worker for session: ${sessionId}`);
468
+ try {
469
+ const scriptPath = __filename || process.argv[1];
470
+ const child = execFile('node', [scriptPath], {
471
+ env: {
472
+ ...process.env,
473
+ _RESPAN_CODEX_WORKER: '1',
474
+ _RESPAN_CODEX_SESSION: sessionId,
475
+ _RESPAN_CODEX_FILE: sessionFile,
476
+ _RESPAN_CODEX_CWD: cwd,
477
+ },
478
+ stdio: 'ignore',
479
+ detached: true,
480
+ });
481
+ child.unref();
482
+ debug('Worker launched');
483
+ }
484
+ catch (e) {
485
+ log('ERROR', `Failed to fork worker: ${e}`);
486
+ }
487
+ process.exit(0);
488
+ }
489
+ main();