664 lines
16 KiB
Go
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
|
|
}
|