feat(runtimes): add Runtimes tab with usage tracking and connection test
Add a new "Runtimes" sidebar tab to manage local agent runtimes with three main capabilities: runtime status overview, token usage tracking (reading Claude Code and Codex CLI local JSONL logs via daemon), and an interactive connection test that sends a ping through the daemon to verify end-to-end agent CLI connectivity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6fd0e2b319
commit
903fbee55d
24 changed files with 1773 additions and 9 deletions
171
server/internal/daemon/usage/codex.go
Normal file
171
server/internal/daemon/usage/codex.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package usage
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scanCodex reads Codex CLI session logs from ~/.codex/sessions/YYYY/MM/DD/*.jsonl
|
||||
// and extracts token usage from "token_count" event lines.
|
||||
func (s *Scanner) scanCodex() []Record {
|
||||
root := codexLogRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Glob for session files: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
||||
pattern := filepath.Join(root, "*", "*", "*", "*.jsonl")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
s.logger.Debug("codex glob error", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allRecords []Record
|
||||
for _, f := range files {
|
||||
record := s.parseCodexFile(f)
|
||||
if record != nil {
|
||||
allRecords = append(allRecords, *record)
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRecords(allRecords)
|
||||
}
|
||||
|
||||
// codexLogRoot returns the Codex sessions directory.
|
||||
func codexLogRoot() string {
|
||||
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
|
||||
dir := filepath.Join(codexHome, "sessions")
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
dir := filepath.Join(home, ".codex", "sessions")
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// codexEvent represents a line in a Codex session JSONL file.
|
||||
type codexEvent struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Payload *struct {
|
||||
Type string `json:"type"`
|
||||
Msg json.RawMessage `json:"msg"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// codexTokenCount represents the token_count info structure.
|
||||
type codexTokenCount struct {
|
||||
Info *struct {
|
||||
TotalTokenUsage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CachedInputTokens int64 `json:"cached_input_tokens"`
|
||||
ReasoningOutputTokens int64 `json:"reasoning_output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
} `json:"total_token_usage"`
|
||||
Model string `json:"model"`
|
||||
} `json:"info"`
|
||||
}
|
||||
|
||||
// parseCodexFile extracts the final cumulative token_count from a Codex session file.
|
||||
// Returns nil if no usage data found.
|
||||
func (s *Scanner) parseCodexFile(path string) *Record {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Extract date from directory path: .../sessions/YYYY/MM/DD/file.jsonl
|
||||
date := extractDateFromPath(path)
|
||||
if date == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastUsage *codexTokenCount
|
||||
var lastModel string
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
// Fast pre-filter
|
||||
if !bytesContains(line, `"token_count"`) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try direct event format: {"type": "event_msg", "payload": {"type": "token_count", ...}}
|
||||
var evt codexEvent
|
||||
if err := json.Unmarshal(line, &evt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if payload contains token_count
|
||||
if evt.Payload != nil && evt.Payload.Type == "token_count" {
|
||||
var tc codexTokenCount
|
||||
if err := json.Unmarshal(evt.Payload.Msg, &tc); err == nil && tc.Info != nil && tc.Info.TotalTokenUsage != nil {
|
||||
lastUsage = &tc
|
||||
if tc.Info.Model != "" {
|
||||
lastModel = tc.Info.Model
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Also try flat format where msg is at top level
|
||||
var tc codexTokenCount
|
||||
if err := json.Unmarshal(line, &tc); err == nil && tc.Info != nil && tc.Info.TotalTokenUsage != nil {
|
||||
lastUsage = &tc
|
||||
if tc.Info.Model != "" {
|
||||
lastModel = tc.Info.Model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastUsage == nil || lastUsage.Info == nil || lastUsage.Info.TotalTokenUsage == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
model := lastModel
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
|
||||
usage := lastUsage.Info.TotalTokenUsage
|
||||
return &Record{
|
||||
Date: date,
|
||||
Provider: "codex",
|
||||
Model: model,
|
||||
InputTokens: usage.InputTokens,
|
||||
OutputTokens: usage.OutputTokens + usage.ReasoningOutputTokens,
|
||||
CacheReadTokens: usage.CachedInputTokens,
|
||||
CacheWriteTokens: 0, // Codex doesn't have cache write tokens
|
||||
}
|
||||
}
|
||||
|
||||
// extractDateFromPath extracts YYYY-MM-DD from a path like .../sessions/2026/03/26/file.jsonl
|
||||
func extractDateFromPath(path string) string {
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
// Look for sessions/YYYY/MM/DD pattern
|
||||
for i := 0; i < len(parts)-3; i++ {
|
||||
if parts[i] == "sessions" && len(parts[i+1]) == 4 && len(parts[i+2]) == 2 && len(parts[i+3]) == 2 {
|
||||
return parts[i+1] + "-" + parts[i+2] + "-" + parts[i+3]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue