@ghl-ai/aw 0.1.37-beta.74 → 0.1.37-beta.76

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.
@@ -124,6 +124,35 @@ function loadPricing() {
124
124
  // Loaded once per process (each hook event is a separate node invocation)
125
125
  const PRICING = loadPricing();
126
126
 
127
+ // ── Cursor stdin normalizer ──────────────────────────────────────────────────
128
+ // Cursor sends a different JSON shape than Claude Code. This function normalizes
129
+ // it to the format our handlers expect, so all handlers work uniformly.
130
+ //
131
+ // Cursor stdin shape:
132
+ // { conversation_id, generation_id, model, text, hook_event_name,
133
+ // cursor_version, workspace_roots: ["/path/..."], attachments? }
134
+ //
135
+ // Claude Code stdin shape:
136
+ // { session_id, transcript_path, cwd, model, status, ... }
137
+
138
+ function normalizeCursorInput(input) {
139
+ if (!input.conversation_id && !input.hook_event_name) return input;
140
+
141
+ const cwd = input.workspace_roots?.[0]
142
+ || process.env.CURSOR_PROJECT_DIR
143
+ || process.cwd();
144
+
145
+ return {
146
+ ...input,
147
+ session_id: input.conversation_id || input.session_id,
148
+ cwd,
149
+ model: (input.model && input.model !== 'default') ? input.model : null,
150
+ platform_version: input.cursor_version || input.platform_version || null,
151
+ _cursor_text: input.text || null,
152
+ _cursor_generation_id: input.generation_id || null,
153
+ };
154
+ }
155
+
127
156
  // ── Helpers ──────────────────────────────────────────────────────────────────
128
157
 
129
158
  function resolveApiUrl() {
@@ -387,6 +416,44 @@ function extractFromTranscript(transcriptPath) {
387
416
  }
388
417
  }
389
418
 
419
+ /**
420
+ * Estimate token counts from Cursor's inline response text.
421
+ * Cursor doesn't provide token usage, so we estimate ~4 chars/token.
422
+ */
423
+ function estimateFromCursorText(text, model) {
424
+ if (!text) return null;
425
+ const outputChars = text.length;
426
+ const outputTokens = Math.round(outputChars / 4);
427
+ const inputTokens = Math.round(outputTokens * 1.5); // rough estimate: inputs > outputs
428
+
429
+ const prUrls = new Set();
430
+ scanForPrUrls(text, prUrls);
431
+
432
+ const mcpToolsUsed = new Set();
433
+ const mcpRe = /mcp__\w+/g;
434
+ const mcpMatches = text.match(mcpRe);
435
+ if (mcpMatches) for (const m of mcpMatches) mcpToolsUsed.add(m.slice(0, 128));
436
+
437
+ return {
438
+ inputTokens,
439
+ outputTokens,
440
+ cacheCreation: 0,
441
+ cacheRead: 0,
442
+ totalTokens: inputTokens + outputTokens,
443
+ model: model || null,
444
+ command: null,
445
+ agentsUsed: [],
446
+ skillsApplied: [],
447
+ mcpToolsUsed: [...mcpToolsUsed],
448
+ toolTotal: 0,
449
+ toolPassed: 0,
450
+ toolFailed: 0,
451
+ prUrls: [...prUrls],
452
+ errorType: null,
453
+ errorMessage: null,
454
+ };
455
+ }
456
+
390
457
  /** Scan text for GitHub PR URLs */
391
458
  function scanForPrUrls(text, prUrlSet) {
392
459
  if (!text) return;
@@ -577,9 +644,10 @@ async function pushPerfSummaries(cwd, transcript, deltaTotal, deltaCost, model,
577
644
  /**
578
645
  * SessionStart — write session metadata. No network.
579
646
  */
580
- export async function handleSessionStart(input) {
581
- const sessionId = input.session_id || input.conversation_id || `s-${Date.now()}`;
582
- const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.env.CURSOR_PROJECT_DIR || process.cwd();
647
+ export async function handleSessionStart(rawInput) {
648
+ const input = normalizeCursorInput(rawInput);
649
+ const sessionId = input.session_id || `s-${Date.now()}`;
650
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
583
651
 
584
652
  // Refresh pricing from LiteLLM if stale (>24h). Non-blocking — if it fails,
585
653
  // existing cached pricing or hardcoded fallback is used.
@@ -611,14 +679,19 @@ export async function handleSessionStart(input) {
611
679
  * Stop — read transcript, compute token delta, append costs.jsonl. No network.
612
680
  * Also scans for PR URLs and MCP tools.
613
681
  */
614
- export async function handleStop(input) {
615
- const sessionId = input.session_id || input.conversation_id || 'unknown';
682
+ export async function handleStop(rawInput) {
683
+ const input = normalizeCursorInput(rawInput);
684
+ const sessionId = input.session_id || 'unknown';
616
685
  const transcriptPath = input.transcript_path || process.env.CURSOR_TRANSCRIPT_PATH || null;
617
- const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.env.CURSOR_PROJECT_DIR || process.cwd();
686
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
618
687
  const status = input.status || 'completed';
619
688
  const stdinModel = input.model || null;
620
689
 
621
- const transcript = extractFromTranscript(transcriptPath);
690
+ // For Cursor: if no transcript file, estimate from inline text
691
+ let transcript = extractFromTranscript(transcriptPath);
692
+ if (!transcript && input._cursor_text) {
693
+ transcript = estimateFromCursorText(input._cursor_text, stdinModel);
694
+ }
622
695
  const model = stdinModel || transcript?.model || 'unknown';
623
696
 
624
697
  // ── Token delta via snapshot diffing ─────────────────────────────────────
@@ -719,12 +792,109 @@ export async function handleStop(input) {
719
792
  await pushPerfSummaries(cwd, transcript, deltaTotal, deltaCost, model, input, session?.namespace);
720
793
  }
721
794
 
795
+ /**
796
+ * AfterAgentResponse — Cursor-specific: fires after every agent response.
797
+ * Creates session if missing (Cursor's sessionStart is unreliable), then
798
+ * estimates tokens from the response text and appends to costs.jsonl.
799
+ * Identical logic to handleStop but triggered more reliably in Cursor.
800
+ */
801
+ export async function handleAfterAgentResponse(rawInput) {
802
+ const input = normalizeCursorInput(rawInput);
803
+ const sessionId = input.session_id || 'unknown';
804
+ const cwd = input.cwd || process.cwd();
805
+ const stdinModel = input.model || null;
806
+
807
+ // Ensure session exists — Cursor's sessionStart hook can be flaky
808
+ let session = readSession(sessionId);
809
+ if (!session) {
810
+ await handleSessionStart(rawInput);
811
+ session = readSession(sessionId);
812
+ if (!session) return;
813
+ }
814
+
815
+ // Estimate tokens from Cursor's inline text
816
+ const transcript = input._cursor_text
817
+ ? estimateFromCursorText(input._cursor_text, stdinModel)
818
+ : null;
819
+ if (!transcript) return; // no text content = nothing to track
820
+
821
+ const model = stdinModel || transcript.model || session.model || 'unknown';
822
+
823
+ // ── Token delta via snapshot diffing (same as handleStop) ──────────────
824
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
825
+ const snapPath = snapshotPath(sessionId);
826
+ let prevSnapshot = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
827
+ if (existsSync(snapPath)) {
828
+ try { prevSnapshot = JSON.parse(readFileSync(snapPath, 'utf8')); } catch { /* corrupted */ }
829
+ }
830
+
831
+ // For afterAgentResponse, each event is a new response — use absolute values as delta
832
+ const deltaInput = transcript.inputTokens || 0;
833
+ const deltaOutput = transcript.outputTokens || 0;
834
+ const deltaCacheCreation = 0;
835
+ const deltaCacheRead = 0;
836
+ const deltaTotal = deltaInput + deltaOutput;
837
+ const deltaCost = estimateCost(model, deltaInput, deltaOutput, deltaCacheCreation, deltaCacheRead);
838
+
839
+ // Update snapshot with cumulative totals
840
+ try {
841
+ writeFileSync(snapPath, JSON.stringify({
842
+ input: (prevSnapshot.input || 0) + deltaInput,
843
+ output: (prevSnapshot.output || 0) + deltaOutput,
844
+ cacheCreation: prevSnapshot.cacheCreation || 0,
845
+ cacheRead: prevSnapshot.cacheRead || 0,
846
+ ts: new Date().toISOString(),
847
+ }));
848
+ } catch { /* best effort */ }
849
+
850
+ // ── Append to costs.jsonl with incrementing entry_id ──────────────────
851
+ const entryId = (session.entry_id || 0) + 1;
852
+
853
+ try {
854
+ const costPath = costsJsonlPath(sessionId);
855
+ const record = {
856
+ entry_id: entryId,
857
+ ts: new Date().toISOString(),
858
+ session_id: sessionId,
859
+ model,
860
+ input_tokens: deltaInput,
861
+ output_tokens: deltaOutput,
862
+ cache_creation: 0,
863
+ cache_read: 0,
864
+ total_tokens: deltaTotal,
865
+ cost_usd: Math.round(deltaCost * 1_000_000) / 1_000_000,
866
+ status: 'completed',
867
+ platform: 'cursor',
868
+ };
869
+ appendFileSync(costPath, JSON.stringify(record) + '\n');
870
+ } catch { /* never block */ }
871
+
872
+ // ── Update session metadata ───────────────────────────────────────────
873
+ session.entry_id = entryId;
874
+ session.model = model !== 'unknown' ? model : session.model;
875
+ if (input.platform_version) session.platform_version = input.platform_version;
876
+
877
+ if (transcript.prUrls?.length) {
878
+ const existing = new Set(session.pr_urls || []);
879
+ for (const url of transcript.prUrls) existing.add(url);
880
+ session.pr_urls = [...existing];
881
+ }
882
+ if (transcript.mcpToolsUsed?.length) {
883
+ const existing = new Set(session.mcp_tools || []);
884
+ for (const tool of transcript.mcpToolsUsed) existing.add(tool);
885
+ session.mcp_tools = [...existing];
886
+ }
887
+
888
+ writeSession(sessionId, session);
889
+ }
890
+
722
891
  /**
723
892
  * PreCompact — aggregate unflushed costs.jsonl entries, POST to API.
724
893
  * This is the heartbeat for long-running sessions.
725
894
  */
726
- export async function handlePreCompact(input) {
727
- const sessionId = input.session_id || input.conversation_id || 'unknown';
895
+ export async function handlePreCompact(rawInput) {
896
+ const input = normalizeCursorInput(rawInput);
897
+ const sessionId = input.session_id || 'unknown';
728
898
  const session = readSession(sessionId);
729
899
  if (!session) return; // no session metadata — nothing to flush
730
900
 
@@ -816,8 +986,9 @@ export async function handlePreCompact(input) {
816
986
  /**
817
987
  * SessionEnd — final flush + session duration + cleanup.
818
988
  */
819
- export async function handleSessionEnd(input) {
820
- const sessionId = input.session_id || input.conversation_id || 'unknown';
989
+ export async function handleSessionEnd(rawInput) {
990
+ const input = normalizeCursorInput(rawInput);
991
+ const sessionId = input.session_id || 'unknown';
821
992
  const session = readSession(sessionId);
822
993
  if (!session) return;
823
994
 
@@ -8,10 +8,11 @@ export const CAPABILITIES = {
8
8
  description: 'Token tracking, cost estimation, API push',
9
9
  module: '../capabilities/telemetry.mjs',
10
10
  hooks: {
11
- 'session-start': { handler: 'handleSessionStart', timeout: 5000 },
12
- 'stop': { handler: 'handleStop', timeout: 10000 },
13
- 'pre-compact': { handler: 'handlePreCompact', timeout: 15000 },
14
- 'session-end': { handler: 'handleSessionEnd', timeout: 15000 },
11
+ 'session-start': { handler: 'handleSessionStart', timeout: 5000 },
12
+ 'stop': { handler: 'handleStop', timeout: 10000 },
13
+ 'after-agent-response': { handler: 'handleAfterAgentResponse', timeout: 10000 },
14
+ 'pre-compact': { handler: 'handlePreCompact', timeout: 15000 },
15
+ 'session-end': { handler: 'handleSessionEnd', timeout: 15000 },
15
16
  },
16
17
  },
17
18
  'activity-tracker': {
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // after-agent-response.mjs — AfterAgentResponse dispatcher.
3
+ // Fires after every Cursor agent response. Primary telemetry signal for Cursor.
4
+ import { run } from './dispatch.mjs';
5
+ run('after-agent-response');
package/integrate.mjs CHANGED
@@ -519,6 +519,7 @@ export function installIdeHooks() {
519
519
  'shared/stop.mjs',
520
520
  'shared/pre-compact.mjs',
521
521
  'shared/session-end.mjs',
522
+ 'shared/after-agent-response.mjs',
522
523
  'capabilities/telemetry.mjs',
523
524
  ];
524
525
 
@@ -545,10 +546,11 @@ export function installIdeHooks() {
545
546
 
546
547
  // Dispatcher commands — one per hook event
547
548
  const dispatchers = {
548
- SessionStart: { cmd: `node "${join(hooksDir, 'shared', 'session-start.mjs')}"`, timeout: 10 },
549
- Stop: { cmd: `node "${join(hooksDir, 'shared', 'stop.mjs')}"`, timeout: 15 },
550
- PreCompact: { cmd: `node "${join(hooksDir, 'shared', 'pre-compact.mjs')}"`, timeout: 20 },
551
- SessionEnd: { cmd: `node "${join(hooksDir, 'shared', 'session-end.mjs')}"`, timeout: 20 },
549
+ SessionStart: { cmd: `node "${join(hooksDir, 'shared', 'session-start.mjs')}"`, timeout: 10 },
550
+ Stop: { cmd: `node "${join(hooksDir, 'shared', 'stop.mjs')}"`, timeout: 15 },
551
+ AfterAgentResponse:{ cmd: `node "${join(hooksDir, 'shared', 'after-agent-response.mjs')}"`, timeout: 15 },
552
+ PreCompact: { cmd: `node "${join(hooksDir, 'shared', 'pre-compact.mjs')}"`, timeout: 20 },
553
+ SessionEnd: { cmd: `node "${join(hooksDir, 'shared', 'session-end.mjs')}"`, timeout: 20 },
552
554
  };
553
555
 
554
556
  // ── Step 2: Claude Code — wire 4 events (merge, don't replace) ────
@@ -562,14 +564,17 @@ export function installIdeHooks() {
562
564
  }
563
565
  if (!claudeSettings.hooks) claudeSettings.hooks = {};
564
566
 
565
- // Wire each dispatcher event
566
- for (const [event, { cmd, timeout }] of Object.entries(dispatchers)) {
567
- const statusMessages = {
568
- SessionStart: 'Starting telemetry...',
569
- Stop: 'Recording telemetry...',
570
- PreCompact: 'Flushing telemetry...',
571
- SessionEnd: 'Finalizing telemetry...',
572
- };
567
+ // Claude Code events — skip AfterAgentResponse (Cursor-only)
568
+ const claudeCodeEvents = ['SessionStart', 'Stop', 'PreCompact', 'SessionEnd'];
569
+ const statusMessages = {
570
+ SessionStart: 'Starting telemetry...',
571
+ Stop: 'Recording telemetry...',
572
+ PreCompact: 'Flushing telemetry...',
573
+ SessionEnd: 'Finalizing telemetry...',
574
+ };
575
+
576
+ for (const event of claudeCodeEvents) {
577
+ const { cmd, timeout } = dispatchers[event];
573
578
 
574
579
  // Get existing hooks for this event, preserving non-aw entries
575
580
  const existingHooks = claudeSettings.hooks[event]?.[0]?.hooks || [];
@@ -577,7 +582,6 @@ export function installIdeHooks() {
577
582
  !h.command?.includes('.aw/hooks/') && !h.command?.includes('telemetry-stop.js')
578
583
  );
579
584
 
580
- // Build new hook entry
581
585
  const awHook = {
582
586
  type: 'command',
583
587
  command: cmd,
@@ -618,6 +622,7 @@ export function installIdeHooks() {
618
622
  const cursorEventMap = {
619
623
  SessionStart: 'sessionStart',
620
624
  Stop: 'stop',
625
+ AfterAgentResponse: 'afterAgentResponse',
621
626
  PreCompact: 'preCompact',
622
627
  SessionEnd: 'sessionEnd',
623
628
  };
@@ -697,7 +702,7 @@ export function installIdeHooks() {
697
702
  } catch { /* best effort */ }
698
703
  }
699
704
 
700
- fmt.logSuccess('IDE hooks installed — 4 events (Claude + Cursor) + 2 events (Codex) → ~/.aw/hooks/');
705
+ fmt.logSuccess('IDE hooks installed — 5 events (Claude + Cursor) + 2 events (Codex) → ~/.aw/hooks/');
701
706
  }
702
707
 
703
708
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.37-beta.74",
3
+ "version": "0.1.37-beta.76",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": "bin.js",