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

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.
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // MUST never throw unhandled. Callers (dispatch.mjs) catch, but we guard anyway.
10
10
 
11
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, rmSync } from 'node:fs';
11
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, rmSync, statSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { createHash } from 'node:crypto';
@@ -155,6 +155,13 @@ function normalizeCursorInput(input) {
155
155
 
156
156
  // ── Helpers ──────────────────────────────────────────────────────────────────
157
157
 
158
+ function resolveSessionId(input) {
159
+ return input?.session_id
160
+ || input?.conversation_id
161
+ || process.env.CODEX_THREAD_ID
162
+ || 'unknown';
163
+ }
164
+
158
165
  function resolveApiUrl() {
159
166
  if (process.env.AW_API_URL) return `${process.env.AW_API_URL}/telemetry/ingest`;
160
167
  const configPath = join(HOME, '.aw', 'config.json');
@@ -191,12 +198,71 @@ function resolveNamespace() {
191
198
  } catch { return null; }
192
199
  }
193
200
 
194
- function detectPlatform() {
201
+ function detectPlatform(input) {
202
+ if (input?.hook_event_name || input?.cursor_version || input?.conversation_id) return 'cursor';
203
+ if (input?.platform) return input.platform;
204
+ if (input?.transcript_path) return 'claude-code';
205
+ if (input?.session_id && !process.env.CODEX_THREAD_ID) return 'claude-code';
195
206
  if (process.env.CLAUDECODE) return 'claude-code';
196
207
  if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_PROJECT_DIR) return 'cursor';
197
208
  if (Object.keys(process.env).some(k => k.startsWith('WINDSURF_'))) return 'windsurf';
198
209
  if (Object.keys(process.env).some(k => k.startsWith('CODEX_'))) return 'codex';
199
- return 'claude-code'; // default assumption
210
+ return 'claude-code';
211
+ }
212
+
213
+ function findCodexTranscriptPath(sessionId) {
214
+ if (!sessionId || sessionId === 'unknown') return null;
215
+
216
+ const sessionsRoot = join(HOME, '.codex', 'sessions');
217
+ if (!existsSync(sessionsRoot)) return null;
218
+
219
+ const stack = [sessionsRoot];
220
+ let newestMatch = null;
221
+ let newestStamp = 0;
222
+
223
+ while (stack.length > 0) {
224
+ const dir = stack.pop();
225
+ let entries = [];
226
+ try {
227
+ entries = readdirSync(dir, { withFileTypes: true });
228
+ } catch {
229
+ continue;
230
+ }
231
+
232
+ for (const entry of entries) {
233
+ const fullPath = join(dir, entry.name);
234
+ if (entry.isDirectory()) {
235
+ stack.push(fullPath);
236
+ continue;
237
+ }
238
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
239
+ if (!entry.name.includes(sessionId)) continue;
240
+
241
+ try {
242
+ const stamp = statSync(fullPath).mtimeMs;
243
+ if (Number.isFinite(stamp) && stamp >= newestStamp) {
244
+ newestStamp = stamp;
245
+ newestMatch = fullPath;
246
+ }
247
+ } catch {
248
+ newestMatch = newestMatch || fullPath;
249
+ }
250
+ }
251
+ }
252
+
253
+ return newestMatch;
254
+ }
255
+
256
+ function classifyError(errStr) {
257
+ if (!errStr) return { errorType: null, errorMessage: null };
258
+ const errorMessage = String(errStr).slice(0, 1024);
259
+ let errorType = 'unknown';
260
+ if (/rate.?limit|429|too many requests/i.test(errorMessage)) errorType = 'rate_limit';
261
+ else if (/context.?(overflow|length|window|limit|too long)/i.test(errorMessage)) errorType = 'context_overflow';
262
+ else if (/timeout|timed?\s*out|ETIMEDOUT/i.test(errorMessage)) errorType = 'timeout';
263
+ else if (/cancel|abort|interrupt/i.test(errorMessage)) errorType = 'user_cancel';
264
+ else if (/tool.?(fail|error)|execution.?error|process exited with code [1-9]/i.test(errorMessage)) errorType = 'tool_failure';
265
+ return { errorType, errorMessage };
200
266
  }
201
267
 
202
268
  function computeProjectHash(cwd) {
@@ -360,14 +426,7 @@ function extractFromTranscript(transcriptPath) {
360
426
  const entryError = entry.error || entry.message?.error;
361
427
  if (entryError) {
362
428
  const errStr = typeof entryError === 'string' ? entryError : (entryError.message || JSON.stringify(entryError));
363
- errorMessage = errStr.slice(0, 1024);
364
- // Classify error type
365
- if (/rate.?limit|429|too many requests/i.test(errStr)) errorType = 'rate_limit';
366
- else if (/context.?(overflow|length|window|limit|too long)/i.test(errStr)) errorType = 'context_overflow';
367
- else if (/timeout|timed?\s*out|ETIMEDOUT/i.test(errStr)) errorType = 'timeout';
368
- else if (/cancel|abort|interrupt/i.test(errStr)) errorType = 'user_cancel';
369
- else if (/tool.?(fail|error)|execution.?error/i.test(errStr)) errorType = 'tool_failure';
370
- else errorType = 'unknown';
429
+ ({ errorType, errorMessage } = classifyError(errStr));
371
430
  }
372
431
  } catch { /* skip malformed lines */ }
373
432
  }
@@ -416,6 +475,129 @@ function extractFromTranscript(transcriptPath) {
416
475
  }
417
476
  }
418
477
 
478
+ function extractFromCodexSession(transcriptPath) {
479
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
480
+
481
+ try {
482
+ const content = readFileSync(transcriptPath, 'utf8');
483
+ const lines = content.split('\n').filter(Boolean);
484
+
485
+ let lastInputTokens = 0;
486
+ let lastOutputTokens = 0;
487
+ let lastCacheCreation = 0;
488
+ let lastCacheRead = 0;
489
+ let model = null;
490
+ let command = null;
491
+ let cwd = null;
492
+ let platformVersion = null;
493
+ const agentsUsed = new Set();
494
+ const skillsApplied = new Set();
495
+ const mcpToolsUsed = new Set();
496
+ const prUrls = new Set();
497
+ let toolTotal = 0;
498
+ let toolFailed = 0;
499
+ let errorType = null;
500
+ let errorMessage = null;
501
+
502
+ for (const line of lines) {
503
+ let entry;
504
+ try {
505
+ entry = JSON.parse(line);
506
+ } catch {
507
+ continue;
508
+ }
509
+
510
+ if (entry.type === 'session_meta') {
511
+ const meta = entry.payload || {};
512
+ cwd = meta.cwd || cwd;
513
+ platformVersion = meta.cli_version || platformVersion;
514
+ }
515
+
516
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
517
+ const totals = entry.payload?.info?.total_token_usage || {};
518
+ lastInputTokens = totals.input_tokens || 0;
519
+ lastOutputTokens = (totals.output_tokens || 0) + (totals.reasoning_output_tokens || 0);
520
+ lastCacheRead = totals.cached_input_tokens || 0;
521
+ }
522
+
523
+ if (entry.type === 'response_item') {
524
+ const payload = entry.payload || {};
525
+ if (payload.type === 'function_call') {
526
+ const name = payload.name || '';
527
+ const argsRaw = typeof payload.arguments === 'string' ? payload.arguments : JSON.stringify(payload.arguments || {});
528
+
529
+ if (name) toolTotal++;
530
+ if (name === 'spawn_agent') {
531
+ try {
532
+ const parsed = JSON.parse(argsRaw || '{}');
533
+ const agentType = parsed.agent_type || parsed.model || 'spawn_agent';
534
+ agentsUsed.add(String(agentType).slice(0, 128));
535
+ } catch {
536
+ agentsUsed.add('spawn_agent');
537
+ }
538
+ }
539
+ if (name === 'Skill') {
540
+ try {
541
+ const parsed = JSON.parse(argsRaw || '{}');
542
+ if (parsed.skill) {
543
+ command = String(parsed.skill).slice(0, 128);
544
+ skillsApplied.add(command);
545
+ }
546
+ } catch { /* ignore */ }
547
+ }
548
+ if (name.startsWith('mcp__')) {
549
+ mcpToolsUsed.add(name.slice(0, 128));
550
+ }
551
+ scanForPrUrls(argsRaw, prUrls);
552
+ }
553
+
554
+ if (payload.type === 'function_call_output') {
555
+ const outputText = String(payload.output || '');
556
+ scanForPrUrls(outputText, prUrls);
557
+ const failureMatch = outputText.match(/Process exited with code\s+(\d+)/i);
558
+ if (failureMatch) {
559
+ const exitCode = Number.parseInt(failureMatch[1], 10);
560
+ if (Number.isFinite(exitCode) && exitCode !== 0) {
561
+ toolFailed++;
562
+ ({ errorType, errorMessage } = classifyError(outputText));
563
+ }
564
+ }
565
+ }
566
+
567
+ if (payload.type === 'message') {
568
+ const textContent = Array.isArray(payload.content)
569
+ ? payload.content.map(part => part?.text || '').join('\n')
570
+ : '';
571
+ scanForPrUrls(textContent, prUrls);
572
+ }
573
+ }
574
+ }
575
+
576
+ return {
577
+ inputTokens: lastInputTokens,
578
+ outputTokens: lastOutputTokens,
579
+ cacheCreation: lastCacheCreation,
580
+ cacheRead: lastCacheRead,
581
+ totalTokens: lastInputTokens + lastOutputTokens + lastCacheCreation + lastCacheRead,
582
+ model,
583
+ command,
584
+ cwd,
585
+ platformVersion,
586
+ agentsUsed: [...agentsUsed],
587
+ skillsApplied: [...skillsApplied],
588
+ mcpToolsUsed: [...mcpToolsUsed],
589
+ toolTotal,
590
+ toolPassed: Math.max(0, toolTotal - toolFailed),
591
+ toolFailed,
592
+ prUrls: [...prUrls],
593
+ errorType,
594
+ errorMessage,
595
+ };
596
+ } catch {
597
+ return null;
598
+ }
599
+ }
600
+
419
601
  /**
420
602
  * Estimate token counts from Cursor's inline response text.
421
603
  * Cursor doesn't provide token usage, so we estimate ~4 chars/token.
@@ -646,8 +828,14 @@ async function pushPerfSummaries(cwd, transcript, deltaTotal, deltaCost, model,
646
828
  */
647
829
  export async function handleSessionStart(rawInput) {
648
830
  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();
831
+ const sessionId = resolveSessionId(input);
832
+ const codexTranscriptPath = detectPlatform(input) === 'codex'
833
+ ? findCodexTranscriptPath(sessionId)
834
+ : null;
835
+ const codexTranscript = codexTranscriptPath
836
+ ? extractFromCodexSession(codexTranscriptPath)
837
+ : null;
838
+ const cwd = input.cwd || codexTranscript?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
651
839
 
652
840
  // Refresh pricing from LiteLLM if stale (>24h). Non-blocking — if it fails,
653
841
  // existing cached pricing or hardcoded fallback is used.
@@ -657,11 +845,11 @@ export async function handleSessionStart(rawInput) {
657
845
  session_id: sessionId,
658
846
  branch: getBranch(cwd),
659
847
  project_hash: computeProjectHash(cwd),
660
- platform: detectPlatform(),
661
- platform_version: input.platform_version || null,
848
+ platform: detectPlatform(rawInput),
849
+ platform_version: input.platform_version || codexTranscript?.platformVersion || null,
662
850
  namespace: resolveNamespace(),
663
851
  start_ts: new Date().toISOString(),
664
- model: input.model || null,
852
+ model: input.model || codexTranscript?.model || null,
665
853
  cwd,
666
854
  entry_id: 0, // monotonic counter for costs.jsonl entries
667
855
  last_flushed_entry: 0, // checkpoint: last entry_id pushed to API
@@ -681,17 +869,25 @@ export async function handleSessionStart(rawInput) {
681
869
  */
682
870
  export async function handleStop(rawInput) {
683
871
  const input = normalizeCursorInput(rawInput);
684
- const sessionId = input.session_id || 'unknown';
685
- const transcriptPath = input.transcript_path || process.env.CURSOR_TRANSCRIPT_PATH || null;
872
+ const sessionId = resolveSessionId(input);
873
+ const platform = detectPlatform(rawInput);
874
+ const transcriptPath = input.transcript_path
875
+ || process.env.CURSOR_TRANSCRIPT_PATH
876
+ || (platform === 'codex' ? findCodexTranscriptPath(sessionId) : null);
686
877
  const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
687
878
  const status = input.status || 'completed';
688
879
  const stdinModel = input.model || null;
689
880
 
690
881
  // For Cursor: if no transcript file, estimate from inline text
691
- let transcript = extractFromTranscript(transcriptPath);
882
+ let transcript = platform === 'codex'
883
+ ? extractFromCodexSession(transcriptPath)
884
+ : extractFromTranscript(transcriptPath);
692
885
  if (!transcript && input._cursor_text) {
693
886
  transcript = estimateFromCursorText(input._cursor_text, stdinModel);
694
887
  }
888
+ if (!transcript && platform === 'codex' && transcriptPath) {
889
+ transcript = extractFromCodexSession(transcriptPath);
890
+ }
695
891
  const model = stdinModel || transcript?.model || 'unknown';
696
892
 
697
893
  // ── Token delta via snapshot diffing ─────────────────────────────────────
@@ -725,6 +921,16 @@ export async function handleStop(rawInput) {
725
921
 
726
922
  // ── Append to costs.jsonl with incrementing entry_id ───────────────────
727
923
  let session = readSession(sessionId);
924
+ if (!session && platform === 'codex') {
925
+ await handleSessionStart({
926
+ ...input,
927
+ session_id: sessionId,
928
+ cwd: input.cwd || transcript?.cwd || cwd,
929
+ model: stdinModel || transcript?.model || null,
930
+ platform_version: input.platform_version || transcript?.platformVersion || null,
931
+ });
932
+ session = readSession(sessionId);
933
+ }
728
934
  const entryId = (session?.entry_id || 0) + 1;
729
935
 
730
936
  try {
@@ -741,7 +947,7 @@ export async function handleStop(rawInput) {
741
947
  total_tokens: deltaTotal,
742
948
  cost_usd: Math.round(deltaCost * 1_000_000) / 1_000_000,
743
949
  status,
744
- platform: detectPlatform(),
950
+ platform,
745
951
  };
746
952
  appendFileSync(costPath, JSON.stringify(record) + '\n');
747
953
  } catch { /* never block */ }
@@ -750,6 +956,8 @@ export async function handleStop(rawInput) {
750
956
  if (session) {
751
957
  session.entry_id = entryId;
752
958
  session.model = model !== 'unknown' ? model : session.model;
959
+ session.platform_version = input.platform_version || transcript?.platformVersion || session.platform_version;
960
+ session.cwd = transcript?.cwd || session.cwd;
753
961
 
754
962
  // Merge PR URLs
755
963
  if (transcript?.prUrls?.length) {
package/integrate.mjs CHANGED
@@ -681,6 +681,20 @@ export function installIdeHooks() {
681
681
  timeoutSec: timeout,
682
682
  statusMessage: `AW telemetry (${event})`,
683
683
  });
684
+
685
+ // Clean up legacy top-level event wrappers from earlier installs so Codex
686
+ // has a single canonical hook definition and we avoid duplicate execution.
687
+ if (Array.isArray(codexHooksFile[event])) {
688
+ const cleaned = codexHooksFile[event]
689
+ .map(group => {
690
+ if (!group || !Array.isArray(group.hooks)) return group;
691
+ const hooks = group.hooks.filter(h => !h.command?.includes('.aw/hooks/'));
692
+ return hooks.length > 0 ? { ...group, hooks } : null;
693
+ })
694
+ .filter(Boolean);
695
+ if (cleaned.length > 0) codexHooksFile[event] = cleaned;
696
+ else delete codexHooksFile[event];
697
+ }
684
698
  }
685
699
 
686
700
  writeFileSync(codexHooksPath, JSON.stringify(codexHooksFile, null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.37-beta.76",
3
+ "version": "0.1.37-beta.78",
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",