tokenrace 0.1.20 → 0.1.22

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.
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>tokenrace</title>
7
- <script type="module" crossorigin src="/assets/index-C8zpckDk.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BuT9l_n-.css">
7
+ <script type="module" crossorigin src="/assets/index-Dt3hqx3Z.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BD6TWWBL.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenrace",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Monitor en tiempo real para Claude Code",
5
5
  "bin": {
6
6
  "tokenrace": "bin/cli.js"
package/src/api-routes.js CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { Router } from 'express'
13
13
  import {
14
- getStatus, getSummary, getTimeseries, getProjects,
14
+ getStatus, getSummary, getTimeseries, getTimeseriesByProject, getProjects,
15
15
  getSessions, getUnlabeledSessions, getSessionEvents,
16
16
  getEvents, getTools, getAgents, getModels,
17
17
  labelSession, ignoreSession, reset, resetProject
@@ -114,6 +114,11 @@ export function createRouter({ port = 1337 } = {}) {
114
114
  res.json(getTimeseries(req.query.metric, req.query.from, req.query.bucket))
115
115
  })
116
116
 
117
+ /** GET /api/timeseries/by-project — serie temporal desglosada por proyecto, acepta ?metric, ?from, ?bucket */
118
+ router.get('/api/timeseries/by-project', (req, res) => {
119
+ res.json(getTimeseriesByProject(req.query.metric, req.query.from, req.query.bucket))
120
+ })
121
+
117
122
  /** GET /api/projects — proyectos con métricas, acepta ?from */
118
123
  router.get('/api/projects', (req, res) => {
119
124
  res.json(getProjects(req.query.from))
package/src/server.js CHANGED
@@ -15,7 +15,7 @@ import express from 'express'
15
15
  import path from 'node:path'
16
16
  import { fileURLToPath } from 'node:url'
17
17
  import { parseMetrics, parseEvents, parseTraces } from './otlp-parser.js'
18
- import { processMetric, processEvent, processTrace, loadFromDisk, startAutoSave, saveSync, drainAutoDetectQueue, labelSession } from './store.js'
18
+ import { processMetric, processEvent, loadFromDisk, startAutoSave, saveSync, drainAutoDetectQueue, labelSession } from './store.js'
19
19
  import { detectProjectBySessionId } from './git-detector.js'
20
20
  import { createRouter, broadcast } from './api-routes.js'
21
21
 
@@ -92,9 +92,6 @@ export async function startServer({ port = 1337 } = {}) {
92
92
  */
93
93
  app.post('/v1/traces', (req, res) => {
94
94
  const spans = parseTraces(req.body)
95
- for (const span of spans) {
96
- processTrace(span)
97
- }
98
95
  broadcast('trace', { count: spans.length })
99
96
  res.json({ partialSuccess: {} })
100
97
  })
package/src/store.js CHANGED
@@ -65,7 +65,6 @@ const state = {
65
65
  eventIndex: 0,
66
66
  projects: new Map(), // projectName → ProjectAggregates
67
67
  tools: new Map(), // toolName → ToolStats
68
- agents: new Map(), // agentId → AgentData
69
68
  models: new Map(), // modelName → ModelStats
70
69
  cumulativeValues: new Map(), // clave → último valor acumulativo (no persiste)
71
70
  lastSeen: null,
@@ -97,7 +96,7 @@ function normalizeIncoming(name, value, labels) {
97
96
  switch (name) {
98
97
  case 'claude_code.token.usage': {
99
98
  const type = labels.type
100
- const key = `token:${sid}:${type}:${labels.model ?? ''}:${labels.query_source ?? ''}`
99
+ const key = `token:${sid}:${type}:${labels.model ?? ''}:${labels.query_source ?? ''}:${labels['agent.name'] ?? ''}`
101
100
  const delta = cumulativeDelta(key, value)
102
101
  const nameMap = {
103
102
  'input': 'claude_code.tokens.input',
@@ -110,7 +109,7 @@ function normalizeIncoming(name, value, labels) {
110
109
  }
111
110
 
112
111
  case 'claude_code.cost.usage': {
113
- const key = `cost:${sid}:${labels.model ?? ''}:${labels.query_source ?? ''}:${labels.effort ?? ''}`
112
+ const key = `cost:${sid}:${labels.model ?? ''}:${labels.query_source ?? ''}:${labels.effort ?? ''}:${labels['agent.name'] ?? ''}`
114
113
  const delta = cumulativeDelta(key, value)
115
114
  return { name: 'claude_code.cost', value: delta }
116
115
  }
@@ -409,15 +408,15 @@ export function processEvent({ eventName, timestamp, severity, attributes }) {
409
408
  session.apiRequests++
410
409
  }
411
410
 
412
- // ── Tool stats ──
413
- if (eventName === 'tool_use') {
411
+ // ── Tool stats de sesión ──
412
+ if (eventName === 'tool_result') {
414
413
  session.toolCalls++
415
414
  }
416
415
  }
417
416
 
418
- // ── Tool stats globales ──
419
- if (eventName === 'tool_use') {
420
- const toolName = attributes['tool.name'] ?? attributes.tool ?? 'unknown'
417
+ // ── Tool stats globales — Claude Code envía tool_result (no tool_use) ──
418
+ if (eventName === 'tool_result') {
419
+ const toolName = attributes['tool_name'] ?? attributes['tool.name'] ?? 'unknown'
421
420
 
422
421
  if (!state.tools.has(toolName)) {
423
422
  state.tools.set(toolName, { count: 0, successes: 0, totalDurationMs: 0 })
@@ -425,12 +424,11 @@ export function processEvent({ eventName, timestamp, severity, attributes }) {
425
424
  const tool = state.tools.get(toolName)
426
425
  tool.count++
427
426
 
428
- if (attributes.success === true || attributes['tool.success'] === true) {
427
+ if (attributes.success === true || attributes.success === 'true') {
429
428
  tool.successes++
430
429
  }
431
- if (attributes['tool.duration_ms'] !== undefined) {
432
- tool.totalDurationMs += Number(attributes['tool.duration_ms'])
433
- }
430
+ const durMs = Number(attributes['duration_ms'] ?? attributes['tool.duration_ms'] ?? 0)
431
+ if (durMs > 0) tool.totalDurationMs += durMs
434
432
  }
435
433
 
436
434
  state.lastSeen = timestamp
@@ -439,33 +437,6 @@ export function processEvent({ eventName, timestamp, severity, attributes }) {
439
437
  return eventObj
440
438
  }
441
439
 
442
- /**
443
- * Procesa un span normalizado.
444
- * Solo actúa si el span tiene agentId.
445
- * @param {Object} span
446
- */
447
- export function processTrace(span) {
448
- const attrs = span.attributes
449
- const agentId = attrs['agent.id'] ?? attrs['gen_ai.agent.id'] ?? null
450
- if (!agentId) return
451
-
452
- if (!state.agents.has(agentId)) {
453
- state.agents.set(agentId, {
454
- agentId,
455
- parentAgentId: attrs['agent.parent_id'] ?? null,
456
- tokensInput: 0,
457
- tokensOutput: 0,
458
- cost: 0,
459
- toolCalls: 0,
460
- durationMs: 0
461
- })
462
- }
463
-
464
- const agent = state.agents.get(agentId)
465
- const duration = span.endTime - span.startTime
466
- if (duration > 0) agent.durationMs += duration
467
- }
468
-
469
440
  /**
470
441
  * Asigna un proyecto a una sesión, con aplicación retroactiva.
471
442
  * @param {string} sessionId
@@ -507,7 +478,6 @@ export function reset() {
507
478
  state.eventIndex = 0
508
479
  state.projects.clear()
509
480
  state.tools.clear()
510
- state.agents.clear()
511
481
  state.models.clear()
512
482
  state.cumulativeValues.clear()
513
483
  state.lastSeen = null
@@ -694,6 +664,36 @@ export function getTimeseries(metric, from, bucket) {
694
664
  * Proyectos con sus métricas agregadas filtradas por rango temporal.
695
665
  * @param {string} from
696
666
  */
667
+ /**
668
+ * Serie temporal de una métrica desglosada por proyecto.
669
+ * Devuelve [{ timestamp, projects: { [projectName]: value } }]
670
+ */
671
+ export function getTimeseriesByProject(metric, from, bucket) {
672
+ const minTs = parseTimeRange(from)
673
+ const bucketMs = parseBucket(bucket)
674
+ const points = state.timeseries.get(metric) ?? []
675
+
676
+ // Map: bucketKey → Map<projectName, value>
677
+ const buckets = new Map()
678
+ for (const point of points) {
679
+ if (point.ts < minTs) continue
680
+ const sid = point.labels['session.id']
681
+ if (sid && state.ignoredSessions.has(sid)) continue
682
+ const proj = resolveProject(sid, point.labels.project) ?? '(sin proyecto)'
683
+ const key = Math.floor(point.ts / bucketMs) * bucketMs
684
+ if (!buckets.has(key)) buckets.set(key, new Map())
685
+ const byProj = buckets.get(key)
686
+ byProj.set(proj, (byProj.get(proj) ?? 0) + point.value)
687
+ }
688
+
689
+ return Array.from(buckets.entries())
690
+ .map(([timestamp, byProj]) => ({
691
+ timestamp,
692
+ projects: Object.fromEntries(byProj),
693
+ }))
694
+ .sort((a, b) => a.timestamp - b.timestamp)
695
+ }
696
+
697
697
  export function getProjects(from) {
698
698
  const minTs = parseTimeRange(from)
699
699
  const result = []
@@ -856,24 +856,51 @@ export function getTools(from) {
856
856
  avgDurationMs: stats.count > 0 ? stats.totalDurationMs / stats.count : 0
857
857
  })).sort((a, b) => b.count - a.count)
858
858
 
859
- // Tasa de aprobación/rechazo desde eventos en el rango
859
+ // Tasa de aprobación/rechazo desde tool_result (Claude Code usa decision_type)
860
860
  let approved = 0
861
861
  let rejected = 0
862
862
  for (const event of state.events) {
863
863
  if (event.timestamp < minTs) continue
864
- if (event.eventName !== 'tool_use' && !event.eventName.includes('tool')) continue
865
- if (event.attributes.approved === true) approved++
866
- if (event.attributes.approved === false) rejected++
864
+ if (event.eventName !== 'tool_result') continue
865
+ if (event.attributes['decision_type'] === 'accept') approved++
866
+ else if (event.attributes['decision_type'] === 'reject') rejected++
867
867
  }
868
868
 
869
869
  return { usage, decisionRate: { approved, rejected } }
870
870
  }
871
871
 
872
872
  /**
873
- * Lista de agentes registrados.
873
+ * Estadísticas de subagentes (Task tool), derivadas de las métricas de tokens
874
+ * y coste cuyas labels traen query_source: "subagent" y agent.name.
875
+ * Claude Code no emite eventos/spans con identidad de agente individual, así
876
+ * que se agrega por nombre de agente (p. ej. "Explore", "general-purpose").
874
877
  */
875
878
  export function getAgents() {
876
- return Array.from(state.agents.values())
879
+ const byAgent = new Map() // agentName → { tokensInput, tokensOutput, cost }
880
+
881
+ const metricField = {
882
+ 'claude_code.tokens.input': 'tokensInput',
883
+ 'claude_code.tokens.output': 'tokensOutput',
884
+ 'claude_code.cost': 'cost'
885
+ }
886
+
887
+ for (const [metricName, field] of Object.entries(metricField)) {
888
+ for (const point of state.timeseries.get(metricName) ?? []) {
889
+ const labels = point.labels
890
+ if (labels.query_source !== 'subagent') continue
891
+ const name = labels['agent.name']
892
+ if (!name) continue
893
+
894
+ if (!byAgent.has(name)) {
895
+ byAgent.set(name, { tokensInput: 0, tokensOutput: 0, cost: 0 })
896
+ }
897
+ byAgent.get(name)[field] += point.value
898
+ }
899
+ }
900
+
901
+ return Array.from(byAgent.entries())
902
+ .map(([name, stats]) => ({ name, ...stats }))
903
+ .sort((a, b) => b.cost - a.cost)
877
904
  }
878
905
 
879
906
  /**
@@ -918,7 +945,8 @@ export function saveSync() {
918
945
  events: state.events,
919
946
  eventIndex: state.eventIndex,
920
947
  totalEvents: state.totalEvents,
921
- startTime: state.startTime
948
+ startTime: state.startTime,
949
+ tools: Array.from(state.tools.entries())
922
950
  }
923
951
 
924
952
  fs.writeFileSync(dataFile, JSON.stringify(data), { mode: 0o600 })
@@ -989,6 +1017,13 @@ export function loadFromDisk() {
989
1017
  state.startTime = data.startTime
990
1018
  }
991
1019
 
1020
+ // Restaurar stats de herramientas
1021
+ if (Array.isArray(data.tools)) {
1022
+ for (const [toolName, stats] of data.tools) {
1023
+ state.tools.set(toolName, stats)
1024
+ }
1025
+ }
1026
+
992
1027
  // Re-aplicar mappings a sesiones (timeseries no necesitan propagación:
993
1028
  // resolveProject() consulta sessionMappings primero en todos los lectores)
994
1029
  for (const [sessionId, project] of state.sessionMappings.entries()) {
@@ -1 +0,0 @@
1
- *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,Fira Code,Menlo,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.right-2{right:.5rem}.top-0{top:0}.top-10{top:2.5rem}.top-2{top:.5rem}.z-40{z-index:40}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-2{height:.5rem}.h-48{height:12rem}.h-\[600px\]{height:600px}.h-full{height:100%}.max-h-40{max-height:10rem}.min-h-\[60vh\]{min-height:60vh}.min-h-screen{min-height:100vh}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-32{width:8rem}.w-44{width:11rem}.w-6{width:1.5rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[640px\]{min-width:640px}.min-w-\[720px\]{min-width:720px}.max-w-screen-2xl{max-width:1536px}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-accent-green{--tw-border-opacity: 1;border-color:rgb(255 107 53 / var(--tw-border-opacity, 1))}.border-bg-border{--tw-border-opacity: 1;border-color:rgb(26 26 26 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.bg-accent-green{--tw-bg-opacity: 1;background-color:rgb(255 107 53 / var(--tw-bg-opacity, 1))}.bg-accent-purple{--tw-bg-opacity: 1;background-color:rgb(168 85 247 / var(--tw-bg-opacity, 1))}.bg-accent-teal{--tw-bg-opacity: 1;background-color:rgb(0 212 170 / var(--tw-bg-opacity, 1))}.bg-accent-yellow{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-bg-base{--tw-bg-opacity: 1;background-color:rgb(20 25 31 / var(--tw-bg-opacity, 1))}.bg-bg-card,.bg-bg-subtle{--tw-bg-opacity: 1;background-color:rgb(33 38 43 / var(--tw-bg-opacity, 1))}.bg-text-muted{--tw-bg-opacity: 1;background-color:rgb(68 68 68 / var(--tw-bg-opacity, 1))}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,Fira Code,Menlo,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wider{letter-spacing:.05em}.text-accent-blue{--tw-text-opacity: 1;color:rgb(77 166 255 / var(--tw-text-opacity, 1))}.text-accent-green,.text-accent-orange{--tw-text-opacity: 1;color:rgb(255 107 53 / var(--tw-text-opacity, 1))}.text-accent-purple{--tw-text-opacity: 1;color:rgb(168 85 247 / var(--tw-text-opacity, 1))}.text-accent-teal{--tw-text-opacity: 1;color:rgb(0 212 170 / var(--tw-text-opacity, 1))}.text-accent-yellow{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-text-muted{--tw-text-opacity: 1;color:rgb(68 68 68 / var(--tw-text-opacity, 1))}.text-text-primary{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-text-secondary{--tw-text-opacity: 1;color:rgb(136 136 136 / var(--tw-text-opacity, 1))}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--bg-base: #14191F;--bg-card: #21262B;--bg-card-hover: #272d33;--bg-border: #1a1a1a;--bg-subtle: #21262B;--text-primary: #ffffff;--text-secondary: #888888;--text-muted: #444444;--accent-green: #ff6b35;--accent-blue: #4da6ff;--accent-purple: #a855f7;--accent-orange: #ff6b35;--accent-teal: #00d4aa;--accent-yellow: #fbbf24}*{box-sizing:border-box}html,body,#root{height:100%;margin:0;padding:0;background-color:#14191f;color:#fff}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:#0d0d0d}::-webkit-scrollbar-thumb{background:#333;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#444}.hover\:border-accent-green:hover{--tw-border-opacity: 1;border-color:rgb(255 107 53 / var(--tw-border-opacity, 1))}.hover\:bg-bg-base:hover{--tw-bg-opacity: 1;background-color:rgb(20 25 31 / var(--tw-bg-opacity, 1))}.hover\:bg-bg-card:hover{--tw-bg-opacity: 1;background-color:rgb(33 38 43 / var(--tw-bg-opacity, 1))}.hover\:bg-bg-card-hover:hover{--tw-bg-opacity: 1;background-color:rgb(39 45 51 / var(--tw-bg-opacity, 1))}.hover\:text-accent-orange:hover{--tw-text-opacity: 1;color:rgb(255 107 53 / var(--tw-text-opacity, 1))}.hover\:text-text-primary:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-text-secondary:hover{--tw-text-opacity: 1;color:rgb(136 136 136 / var(--tw-text-opacity, 1))}.hover\:opacity-80:hover{opacity:.8}.focus\:border-accent-green:focus{--tw-border-opacity: 1;border-color:rgb(255 107 53 / var(--tw-border-opacity, 1))}.disabled\:opacity-40:disabled{opacity:.4}@media(min-width:768px){.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}