Files
2026-06-02 23:14:41 +08:00

664 lines
16 KiB
Go

package claudecode
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
"github.com/creack/pty"
)
const (
claudeUsageSessionWindowSeconds = 5 * 60 * 60
claudeUsageWeekWindowSeconds = 7 * 24 * 60 * 60
claudeUsagePollInterval = 100 * time.Millisecond
claudeUsageStableFor = 450 * time.Millisecond
claudeUsageActionGap = 250 * time.Millisecond
)
var (
claudeUsagePercentRe = regexp.MustCompile(`(?i)\b(\d{1,3})\s*%\s*used\b`)
claudeUsageResetLineRe = regexp.MustCompile(`(?i)^resets\s+(.+?)\s*$`)
claudeUsageParenTZRe = regexp.MustCompile(`^(.*?)\s*\(([^()]+)\)\s*$`)
claudeUsageWhitespaceRe = regexp.MustCompile(`[ \t]+`)
claudeUsageRuleLineRe = regexp.MustCompile(`^[\p{Zs}\-─━_=]{4,}$`)
)
type claudeUsageProbeState struct {
promptResponses int
sentWake bool
sentUsage bool
sentEnterRetry bool
sentUsageRetry bool
lastActionAt time.Time
usageSentAt time.Time
}
func (a *Agent) GetUsage(ctx context.Context) (*core.UsageReport, error) {
if _, err := exec.LookPath("claude"); err != nil {
return nil, fmt.Errorf("claudecode: 'claude' CLI not found in PATH")
}
screen, err := a.runClaudeUsageProbe(ctx)
if err != nil {
return nil, err
}
return parseClaudeUsageReport(screen, time.Now())
}
func (a *Agent) runClaudeUsageProbe(ctx context.Context) (string, error) {
probeCtx, cancel := context.WithCancel(ctx)
defer cancel()
workDir, err := os.MkdirTemp("", "cc-connect-claude-usage-*")
if err != nil {
return "", fmt.Errorf("claudecode: create usage temp dir: %w", err)
}
defer os.RemoveAll(workDir)
args := []string{
"--tools", "",
"--permission-mode", "plan",
"--no-chrome",
}
cmd := exec.CommandContext(probeCtx, "claude", args...)
cmd.Dir = workDir
env := filterEnv(os.Environ(), "CLAUDECODE")
env = append(env, "DISABLE_TELEMETRY=true")
env = append(env, "DISABLE_COST_WARNINGS=true")
if extra := a.usageProbeEnv(); len(extra) > 0 {
env = core.MergeEnv(env, extra)
}
cmd.Env = env
var stderr bytes.Buffer
cmd.Stderr = &stderr
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120})
if err != nil {
return "", fmt.Errorf("claudecode: start claude usage probe: %w", err)
}
var waitErr error
processDone := make(chan struct{})
go func() {
waitErr = cmd.Wait()
close(processDone)
}()
terminal := newClaudeUsageTerminal()
readDone := make(chan error, 1)
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if n > 0 {
terminal.Write(buf[:n])
}
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
readDone <- nil
} else {
readDone <- err
}
return
}
}
}()
defer func() {
if err := ptmx.Close(); err != nil {
slog.Warn("claudeSession: close ptmx", "error", err)
}
cancel()
// Wait for reader goroutine to finish so it is never leaked.
<-readDone
select {
case <-processDone:
case <-time.After(2 * time.Second):
if cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
slog.Warn("claudeSession: kill process", "error", err)
}
}
<-processDone
}
}()
ticker := time.NewTicker(claudeUsagePollInterval)
defer ticker.Stop()
var (
state claudeUsageProbeState
lastScreen string
lastChange = time.Now()
usageScreen string
)
for {
select {
case <-probeCtx.Done():
if usageScreen != "" {
return usageScreen, nil
}
if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil {
return "", screenErr
}
return "", fmt.Errorf("claudecode: timed out waiting for Claude Code /usage panel: %w", probeCtx.Err())
case err := <-readDone:
if err != nil {
return "", fmt.Errorf("claudecode: read Claude Code /usage output: %w", err)
}
case <-processDone:
if usageScreen != "" {
return usageScreen, nil
}
if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil {
return "", screenErr
}
if waitErr != nil {
return "", fmt.Errorf("claudecode: Claude Code exited before /usage rendered: %w", waitErr)
}
return "", fmt.Errorf("claudecode: Claude Code exited before /usage rendered")
case <-ticker.C:
screen := normalizeClaudeUsageText(terminal.String())
if screen != lastScreen {
lastScreen = screen
lastChange = time.Now()
}
if usageReady(lastScreen) {
if usageScreen == "" {
usageScreen = lastScreen
}
}
if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil {
return "", screenErr
}
if usageScreen != "" && time.Since(lastChange) >= claudeUsageStableFor {
return usageScreen, nil
}
if action := nextClaudeUsageProbeAction(lastScreen, &state, time.Now()); action != "" {
if _, err := io.WriteString(ptmx, action); err != nil {
return "", fmt.Errorf("claudecode: write Claude Code /usage probe input: %w", err)
}
}
}
}
}
func (a *Agent) usageProbeEnv() []string {
a.mu.Lock()
defer a.mu.Unlock()
return a.runtimeEnvLocked()
}
func nextClaudeUsageProbeAction(screen string, state *claudeUsageProbeState, now time.Time) string {
if now.Sub(state.lastActionAt) < claudeUsageActionGap {
return ""
}
if action := promptActionForScreen(screen); action != "" && state.promptResponses < 6 {
state.promptResponses++
state.lastActionAt = now
return action
}
if usageReady(screen) {
return ""
}
if !state.sentWake {
state.sentWake = true
state.lastActionAt = now
return "\r"
}
if !state.sentUsage {
state.sentUsage = true
state.usageSentAt = now
state.lastActionAt = now
return "/usage\r"
}
if !state.sentEnterRetry && now.Sub(state.usageSentAt) >= 900*time.Millisecond {
state.sentEnterRetry = true
state.lastActionAt = now
return "\r"
}
if !state.sentUsageRetry && now.Sub(state.usageSentAt) >= 1500*time.Millisecond {
state.sentUsageRetry = true
state.lastActionAt = now
return "/usage\r"
}
return ""
}
func promptActionForScreen(screen string) string {
lower := strings.ToLower(screen)
if lower == "" {
return ""
}
if strings.Contains(lower, "quick safety check") || strings.Contains(lower, "yes, i trust this folder") {
return "\r"
}
if (strings.Contains(lower, "telemetry") || strings.Contains(lower, "help improve") || strings.Contains(lower, "usage data")) &&
(strings.Contains(lower, "2. no") || strings.Contains(lower, "2. disable") || strings.Contains(lower, "2. don't")) {
return "\x1b[B\r"
}
if strings.Contains(lower, "enter to confirm") && !usageReady(lower) {
return "\r"
}
return ""
}
func usageReady(screen string) bool {
lower := strings.ToLower(screen)
return strings.Contains(lower, "current session") &&
strings.Contains(lower, "current week") &&
strings.Contains(lower, "resets") &&
claudeUsagePercentRe.MatchString(screen)
}
func normalizeClaudeUsageText(raw string) string {
raw = strings.ReplaceAll(raw, "\r", "\n")
lines := strings.Split(raw, "\n")
out := make([]string, 0, len(lines))
lastBlank := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
if !lastBlank {
out = append(out, "")
lastBlank = true
}
continue
}
line = claudeUsageWhitespaceRe.ReplaceAllString(line, " ")
if claudeUsageRuleLineRe.MatchString(line) {
continue
}
out = append(out, line)
lastBlank = false
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func parseClaudeUsageReport(text string, now time.Time) (*core.UsageReport, error) {
text = normalizeClaudeUsageText(text)
if text == "" {
return nil, fmt.Errorf("claudecode: Claude Code /usage produced empty output")
}
if err := detectClaudeUsageOutputError(text, ""); err != nil {
return nil, err
}
report := &core.UsageReport{
Provider: "claudecode",
}
lines := strings.Split(text, "\n")
session, err := parseClaudeUsageWindow(lines, "Current session", claudeUsageSessionWindowSeconds, now)
if err != nil {
return nil, err
}
week, err := parseClaudeUsageWindow(lines, "Current week", claudeUsageWeekWindowSeconds, now)
if err != nil {
return nil, err
}
report.Buckets = []core.UsageBucket{{
Name: "Usage",
Allowed: true,
Windows: []core.UsageWindow{session, week},
}}
return report, nil
}
func parseClaudeUsageWindow(lines []string, header string, windowSeconds int, now time.Time) (core.UsageWindow, error) {
start := -1
headerLower := strings.ToLower(header)
for i, line := range lines {
lower := strings.ToLower(strings.TrimSpace(line))
if strings.HasPrefix(lower, headerLower) {
start = i
break
}
}
if start < 0 {
return core.UsageWindow{}, fmt.Errorf("claudecode: missing %s block in Claude Code /usage output", header)
}
var (
usedPercent *int
resetRaw string
)
for i := start + 1; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
lower := strings.ToLower(line)
if i > start+1 && (strings.HasPrefix(lower, "current ") || strings.HasPrefix(lower, "extra usage")) {
break
}
if usedPercent == nil {
if m := claudeUsagePercentRe.FindStringSubmatch(line); len(m) == 2 {
v, _ := strconv.Atoi(m[1])
usedPercent = &v
continue
}
}
if resetRaw == "" {
if m := claudeUsageResetLineRe.FindStringSubmatch(line); len(m) == 2 {
resetRaw = strings.TrimSpace(m[1])
}
}
}
if usedPercent == nil {
return core.UsageWindow{}, fmt.Errorf("claudecode: missing usage percentage in %s block", header)
}
window := core.UsageWindow{
Name: header,
UsedPercent: *usedPercent,
WindowSeconds: windowSeconds,
}
if resetRaw != "" {
resetAt, err := parseClaudeUsageResetTime(resetRaw, now)
if err == nil {
resetAfter := int(resetAt.Sub(now).Round(time.Second).Seconds())
if resetAfter < 0 {
resetAfter = 0
}
window.ResetAfterSeconds = resetAfter
window.ResetAtUnix = resetAt.Unix()
}
}
return window, nil
}
func parseClaudeUsageResetTime(raw string, now time.Time) (time.Time, error) {
label := strings.TrimSpace(raw)
loc := now.Location()
if m := claudeUsageParenTZRe.FindStringSubmatch(label); len(m) == 3 {
label = strings.TrimSpace(m[1])
tzName := strings.TrimSpace(m[2])
tzLoc, err := time.LoadLocation(tzName)
if err != nil {
return time.Time{}, fmt.Errorf("unknown timezone %q", tzName)
}
loc = tzLoc
}
nowInLoc := now.In(loc)
for _, layout := range []string{"3:04pm", "3pm"} {
parsed, err := time.ParseInLocation(layout, label, loc)
if err != nil {
continue
}
resetAt := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
if !resetAt.After(nowInLoc) {
resetAt = resetAt.Add(24 * time.Hour)
}
return resetAt, nil
}
for _, layout := range []string{"Jan 2, 3:04pm", "Jan 2, 3pm"} {
parsed, err := time.ParseInLocation(layout, label, loc)
if err != nil {
continue
}
resetAt := time.Date(nowInLoc.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
if !resetAt.After(nowInLoc) {
resetAt = resetAt.AddDate(1, 0, 0)
}
return resetAt, nil
}
return time.Time{}, fmt.Errorf("unsupported reset time %q", raw)
}
func detectClaudeUsageOutputError(screen, stderr string) error {
joined := strings.ToLower(strings.TrimSpace(screen + "\n" + stderr))
switch {
case joined == "":
return nil
case strings.Contains(joined, "unknown command") && strings.Contains(joined, "/usage"):
return fmt.Errorf("claudecode: this Claude Code version does not support /usage; please upgrade Claude Code")
case strings.Contains(joined, "auth login") || strings.Contains(joined, "not logged in") || strings.Contains(joined, "sign in"):
return fmt.Errorf("claudecode: Claude Code is not logged in for /usage; run `claude auth login`")
case strings.Contains(joined, "/usage") && (strings.Contains(joined, "not available") || strings.Contains(joined, "not supported") || strings.Contains(joined, "subscription")):
return fmt.Errorf("claudecode: current Claude account does not support /usage")
default:
return nil
}
}
type claudeUsageTerminal struct {
mu sync.RWMutex
lines [][]rune
row int
col int
}
func newClaudeUsageTerminal() *claudeUsageTerminal {
return &claudeUsageTerminal{
lines: [][]rune{nil},
}
}
func (t *claudeUsageTerminal) Write(p []byte) {
t.mu.Lock()
defer t.mu.Unlock()
for i := 0; i < len(p); {
switch p[i] {
case 0x1b:
next := t.consumeEscape(p[i:])
if next <= 0 {
i++
} else {
i += next
}
case '\r':
t.col = 0
i++
case '\n':
t.row++
t.ensureRow(t.row)
i++
case '\b':
if t.col > 0 {
t.col--
}
i++
case '\t':
advance := 4 - (t.col % 4)
for j := 0; j < advance; j++ {
t.writeRune(' ')
}
i++
default:
if p[i] < 0x20 || p[i] == 0x7f {
i++
continue
}
r, size := utf8.DecodeRune(p[i:])
if r == utf8.RuneError && size == 1 {
i++
continue
}
t.writeRune(r)
i += size
}
}
}
func (t *claudeUsageTerminal) String() string {
t.mu.RLock()
defer t.mu.RUnlock()
lines := make([]string, 0, len(t.lines))
for _, line := range t.lines {
lines = append(lines, strings.TrimRight(string(line), " "))
}
return strings.Join(lines, "\n")
}
func (t *claudeUsageTerminal) consumeEscape(p []byte) int {
if len(p) < 2 {
return len(p)
}
switch p[1] {
case '[':
for i := 2; i < len(p); i++ {
if p[i] >= '@' && p[i] <= '~' {
t.applyCSI(string(p[2:i]), p[i])
return i + 1
}
}
return len(p)
case ']':
for i := 2; i < len(p); i++ {
if p[i] == '\a' {
return i + 1
}
if p[i] == 0x1b && i+1 < len(p) && p[i+1] == '\\' {
return i + 2
}
}
return len(p)
default:
return 2
}
}
func (t *claudeUsageTerminal) applyCSI(params string, final byte) {
switch final {
case 'A', 'B', 'C', 'D':
n := parseCSIInt(params, 1)
switch final {
case 'A':
t.row -= n
if t.row < 0 {
t.row = 0
}
case 'B':
t.row += n
t.ensureRow(t.row)
case 'C':
t.col += n
case 'D':
t.col -= n
if t.col < 0 {
t.col = 0
}
}
case 'H', 'f':
row, col := parseCSICursor(params)
if row < 0 {
row = 0
}
if col < 0 {
col = 0
}
t.row, t.col = row, col
t.ensureRow(t.row)
case 'J':
if params == "2" || params == "" {
t.lines = [][]rune{nil}
t.row, t.col = 0, 0
}
case 'K':
t.ensureRow(t.row)
switch params {
case "", "0":
if t.col < len(t.lines[t.row]) {
t.lines[t.row] = t.lines[t.row][:t.col]
}
case "2":
t.lines[t.row] = nil
t.col = 0
}
default:
}
}
func (t *claudeUsageTerminal) writeRune(r rune) {
if t.row >= maxTerminalRows || t.col >= maxTerminalCols {
return
}
t.ensureCell(t.row, t.col)
if t.row < len(t.lines) && t.col < len(t.lines[t.row]) {
t.lines[t.row][t.col] = r
}
t.col++
}
const maxTerminalRows = 500
const maxTerminalCols = 500
func (t *claudeUsageTerminal) ensureRow(row int) {
if row >= maxTerminalRows {
return
}
for len(t.lines) <= row {
t.lines = append(t.lines, nil)
}
}
func (t *claudeUsageTerminal) ensureCell(row, col int) {
if row >= maxTerminalRows || col >= maxTerminalCols {
return
}
t.ensureRow(row)
for len(t.lines[row]) <= col {
t.lines[row] = append(t.lines[row], ' ')
}
}
func parseCSIInt(raw string, fallback int) int {
raw = strings.TrimPrefix(raw, "?")
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return fallback
}
return n
}
func parseCSICursor(raw string) (int, int) {
raw = strings.TrimPrefix(raw, "?")
if raw == "" {
return 0, 0
}
parts := strings.Split(raw, ";")
row := parseCSIInt(parts[0], 1) - 1
col := 0
if len(parts) > 1 {
col = parseCSIInt(parts[1], 1) - 1
}
return row, col
}