@ghl-ai/aw 0.1.37-beta.75 → 0.1.37-beta.77

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() {
@@ -162,12 +191,13 @@ function resolveNamespace() {
162
191
  } catch { return null; }
163
192
  }
164
193
 
165
- function detectPlatform() {
194
+ function detectPlatform(input) {
195
+ if (input?.hook_event_name || input?.cursor_version || input?.conversation_id) return 'cursor';
166
196
  if (process.env.CLAUDECODE) return 'claude-code';
167
197
  if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_PROJECT_DIR) return 'cursor';
168
198
  if (Object.keys(process.env).some(k => k.startsWith('WINDSURF_'))) return 'windsurf';
169
199
  if (Object.keys(process.env).some(k => k.startsWith('CODEX_'))) return 'codex';
170
- return 'claude-code'; // default assumption
200
+ return 'claude-code';
171
201
  }
172
202
 
173
203
  function computeProjectHash(cwd) {
@@ -387,6 +417,44 @@ function extractFromTranscript(transcriptPath) {
387
417
  }
388
418
  }
389
419
 
420
+ /**
421
+ * Estimate token counts from Cursor's inline response text.
422
+ * Cursor doesn't provide token usage, so we estimate ~4 chars/token.
423
+ */
424
+ function estimateFromCursorText(text, model) {
425
+ if (!text) return null;
426
+ const outputChars = text.length;
427
+ const outputTokens = Math.round(outputChars / 4);
428
+ const inputTokens = Math.round(outputTokens * 1.5); // rough estimate: inputs > outputs
429
+
430
+ const prUrls = new Set();
431
+ scanForPrUrls(text, prUrls);
432
+
433
+ const mcpToolsUsed = new Set();
434
+ const mcpRe = /mcp__\w+/g;
435
+ const mcpMatches = text.match(mcpRe);
436
+ if (mcpMatches) for (const m of mcpMatches) mcpToolsUsed.add(m.slice(0, 128));
437
+
438
+ return {
439
+ inputTokens,
440
+ outputTokens,
441
+ cacheCreation: 0,
442
+ cacheRead: 0,
443
+ totalTokens: inputTokens + outputTokens,
444
+ model: model || null,
445
+ command: null,
446
+ agentsUsed: [],
447
+ skillsApplied: [],
448
+ mcpToolsUsed: [...mcpToolsUsed],
449
+ toolTotal: 0,
450
+ toolPassed: 0,
451
+ toolFailed: 0,
452
+ prUrls: [...prUrls],
453
+ errorType: null,
454
+ errorMessage: null,
455
+ };
456
+ }
457
+
390
458
  /** Scan text for GitHub PR URLs */
391
459
  function scanForPrUrls(text, prUrlSet) {
392
460
  if (!text) return;
@@ -577,9 +645,10 @@ async function pushPerfSummaries(cwd, transcript, deltaTotal, deltaCost, model,
577
645
  /**
578
646
  * SessionStart — write session metadata. No network.
579
647
  */
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();
648
+ export async function handleSessionStart(rawInput) {
649
+ const input = normalizeCursorInput(rawInput);
650
+ const sessionId = input.session_id || `s-${Date.now()}`;
651
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
583
652
 
584
653
  // Refresh pricing from LiteLLM if stale (>24h). Non-blocking — if it fails,
585
654
  // existing cached pricing or hardcoded fallback is used.
@@ -589,7 +658,7 @@ export async function handleSessionStart(input) {
589
658
  session_id: sessionId,
590
659
  branch: getBranch(cwd),
591
660
  project_hash: computeProjectHash(cwd),
592
- platform: detectPlatform(),
661
+ platform: detectPlatform(rawInput),
593
662
  platform_version: input.platform_version || null,
594
663
  namespace: resolveNamespace(),
595
664
  start_ts: new Date().toISOString(),
@@ -611,14 +680,19 @@ export async function handleSessionStart(input) {
611
680
  * Stop — read transcript, compute token delta, append costs.jsonl. No network.
612
681
  * Also scans for PR URLs and MCP tools.
613
682
  */
614
- export async function handleStop(input) {
615
- const sessionId = input.session_id || input.conversation_id || 'unknown';
683
+ export async function handleStop(rawInput) {
684
+ const input = normalizeCursorInput(rawInput);
685
+ const sessionId = input.session_id || 'unknown';
616
686
  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();
687
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
618
688
  const status = input.status || 'completed';
619
689
  const stdinModel = input.model || null;
620
690
 
621
- const transcript = extractFromTranscript(transcriptPath);
691
+ // For Cursor: if no transcript file, estimate from inline text
692
+ let transcript = extractFromTranscript(transcriptPath);
693
+ if (!transcript && input._cursor_text) {
694
+ transcript = estimateFromCursorText(input._cursor_text, stdinModel);
695
+ }
622
696
  const model = stdinModel || transcript?.model || 'unknown';
623
697
 
624
698
  // ── Token delta via snapshot diffing ─────────────────────────────────────
@@ -668,7 +742,7 @@ export async function handleStop(input) {
668
742
  total_tokens: deltaTotal,
669
743
  cost_usd: Math.round(deltaCost * 1_000_000) / 1_000_000,
670
744
  status,
671
- platform: detectPlatform(),
745
+ platform: detectPlatform(rawInput),
672
746
  };
673
747
  appendFileSync(costPath, JSON.stringify(record) + '\n');
674
748
  } catch { /* never block */ }
@@ -719,12 +793,109 @@ export async function handleStop(input) {
719
793
  await pushPerfSummaries(cwd, transcript, deltaTotal, deltaCost, model, input, session?.namespace);
720
794
  }
721
795
 
796
+ /**
797
+ * AfterAgentResponse — Cursor-specific: fires after every agent response.
798
+ * Creates session if missing (Cursor's sessionStart is unreliable), then
799
+ * estimates tokens from the response text and appends to costs.jsonl.
800
+ * Identical logic to handleStop but triggered more reliably in Cursor.
801
+ */
802
+ export async function handleAfterAgentResponse(rawInput) {
803
+ const input = normalizeCursorInput(rawInput);
804
+ const sessionId = input.session_id || 'unknown';
805
+ const cwd = input.cwd || process.cwd();
806
+ const stdinModel = input.model || null;
807
+
808
+ // Ensure session exists — Cursor's sessionStart hook can be flaky
809
+ let session = readSession(sessionId);
810
+ if (!session) {
811
+ await handleSessionStart(rawInput);
812
+ session = readSession(sessionId);
813
+ if (!session) return;
814
+ }
815
+
816
+ // Estimate tokens from Cursor's inline text
817
+ const transcript = input._cursor_text
818
+ ? estimateFromCursorText(input._cursor_text, stdinModel)
819
+ : null;
820
+ if (!transcript) return; // no text content = nothing to track
821
+
822
+ const model = stdinModel || transcript.model || session.model || 'unknown';
823
+
824
+ // ── Token delta via snapshot diffing (same as handleStop) ──────────────
825
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
826
+ const snapPath = snapshotPath(sessionId);
827
+ let prevSnapshot = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
828
+ if (existsSync(snapPath)) {
829
+ try { prevSnapshot = JSON.parse(readFileSync(snapPath, 'utf8')); } catch { /* corrupted */ }
830
+ }
831
+
832
+ // For afterAgentResponse, each event is a new response — use absolute values as delta
833
+ const deltaInput = transcript.inputTokens || 0;
834
+ const deltaOutput = transcript.outputTokens || 0;
835
+ const deltaCacheCreation = 0;
836
+ const deltaCacheRead = 0;
837
+ const deltaTotal = deltaInput + deltaOutput;
838
+ const deltaCost = estimateCost(model, deltaInput, deltaOutput, deltaCacheCreation, deltaCacheRead);
839
+
840
+ // Update snapshot with cumulative totals
841
+ try {
842
+ writeFileSync(snapPath, JSON.stringify({
843
+ input: (prevSnapshot.input || 0) + deltaInput,
844
+ output: (prevSnapshot.output || 0) + deltaOutput,
845
+ cacheCreation: prevSnapshot.cacheCreation || 0,
846
+ cacheRead: prevSnapshot.cacheRead || 0,
847
+ ts: new Date().toISOString(),
848
+ }));
849
+ } catch { /* best effort */ }
850
+
851
+ // ── Append to costs.jsonl with incrementing entry_id ──────────────────
852
+ const entryId = (session.entry_id || 0) + 1;
853
+
854
+ try {
855
+ const costPath = costsJsonlPath(sessionId);
856
+ const record = {
857
+ entry_id: entryId,
858
+ ts: new Date().toISOString(),
859
+ session_id: sessionId,
860
+ model,
861
+ input_tokens: deltaInput,
862
+ output_tokens: deltaOutput,
863
+ cache_creation: 0,
864
+ cache_read: 0,
865
+ total_tokens: deltaTotal,
866
+ cost_usd: Math.round(deltaCost * 1_000_000) / 1_000_000,
867
+ status: 'completed',
868
+ platform: 'cursor',
869
+ };
870
+ appendFileSync(costPath, JSON.stringify(record) + '\n');
871
+ } catch { /* never block */ }
872
+
873
+ // ── Update session metadata ───────────────────────────────────────────
874
+ session.entry_id = entryId;
875
+ session.model = model !== 'unknown' ? model : session.model;
876
+ if (input.platform_version) session.platform_version = input.platform_version;
877
+
878
+ if (transcript.prUrls?.length) {
879
+ const existing = new Set(session.pr_urls || []);
880
+ for (const url of transcript.prUrls) existing.add(url);
881
+ session.pr_urls = [...existing];
882
+ }
883
+ if (transcript.mcpToolsUsed?.length) {
884
+ const existing = new Set(session.mcp_tools || []);
885
+ for (const tool of transcript.mcpToolsUsed) existing.add(tool);
886
+ session.mcp_tools = [...existing];
887
+ }
888
+
889
+ writeSession(sessionId, session);
890
+ }
891
+
722
892
  /**
723
893
  * PreCompact — aggregate unflushed costs.jsonl entries, POST to API.
724
894
  * This is the heartbeat for long-running sessions.
725
895
  */
726
- export async function handlePreCompact(input) {
727
- const sessionId = input.session_id || input.conversation_id || 'unknown';
896
+ export async function handlePreCompact(rawInput) {
897
+ const input = normalizeCursorInput(rawInput);
898
+ const sessionId = input.session_id || 'unknown';
728
899
  const session = readSession(sessionId);
729
900
  if (!session) return; // no session metadata — nothing to flush
730
901
 
@@ -816,8 +987,9 @@ export async function handlePreCompact(input) {
816
987
  /**
817
988
  * SessionEnd — final flush + session duration + cleanup.
818
989
  */
819
- export async function handleSessionEnd(input) {
820
- const sessionId = input.session_id || input.conversation_id || 'unknown';
990
+ export async function handleSessionEnd(rawInput) {
991
+ const input = normalizeCursorInput(rawInput);
992
+ const sessionId = input.session_id || 'unknown';
821
993
  const session = readSession(sessionId);
822
994
  if (!session) return;
823
995
 
@@ -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.75",
3
+ "version": "0.1.37-beta.77",
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",