diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 7333433e8a..4aac65e900 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -108,11 +108,10 @@ wsh editconfig | tab:preset | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key. deprecated in favor of `tab:background` | | tab:background | string | a "bg@" preset to automatically apply to new tabs. e.g. `bg@green`. should match the preset key | | library:taborder | string | order of the Library block's tabs, `"snippets,notes"` (default) or `"notes,snippets"`; the first tab opens by default. Set it by dragging one tab onto the other, or right-click the tab bar → Swap Tab Order | -| agentusage:agent | string | AI agent whose usage the tab bar meter tracks, `"claude"` (default) or `"codex"` (set to an empty string to turn the meter off; it then shows only a gauge icon). Usage is read from the agent's local session logs (`~/.claude/projects`, `~/.codex/sessions`) | -| agentusage:conn | string | connection the agent runs on, e.g. `user@host`; empty means this machine. Remote hosts need wsh installed | +| agentusage:agent | string | AI agent whose usage the tab bar meter tracks, `"claude"` (default) or `"codex"` (set to `"off"` to turn the meter off; it then shows only a gauge icon). Usage is read from the agent's local session logs (`~/.claude/projects`, `~/.codex/sessions`). For Claude Code, exact plan percentages and reset times are used when `~/.claude/rate-limits.json` exists: have your statusline script save them with `echo "$input" \| jq '.rate_limits' > ~/.claude/rate-limits.json` (it updates while a Claude Code session is running) | +| agentusage:conn | string | connection the agent runs on, e.g. `user@host`; empty means this machine. Remote hosts are read through wsh when installed, otherwise over SFTP | | agentusage:sessionhours | int | length in hours of the plan's rolling usage session (default 5, as on Claude Pro/Max); the session opens with the first message, floored to the hour | | agentusage:sessionlimit | int | your own token budget for one session; 0 shows raw usage without a bar | -| agentusage:dailylimit | int | your own token budget for today (input + output + cache-write tokens); 0 shows raw usage without a bar. The plan's real limits are not in the logs | | agentusage:weeklylimit | int | your own token budget for the last 7 days; 0 shows raw usage without a bar | | tab:confirmclose | bool | if set to true, a confirmation dialog will be shown before closing a tab (defaults to false) | | tab:confirmcloserunning | bool | when true (default), closing a tab whose terminals are running a command shows a warning listing them, with Cancel as the default button. Detected via shell integration, for `term:tmux` terminals by asking tmux what each pane runs, and for plain SSH shells by the processes tagged with the terminal's `LC_WAVETERM_BLOCK` (needs the host's sshd to accept `LC_*`, the common default, and a `/proc` filesystem) | diff --git a/frontend/app/tab/agentusage.tsx b/frontend/app/tab/agentusage.tsx index e50b41a07f..4535afb3da 100644 --- a/frontend/app/tab/agentusage.tsx +++ b/frontend/app/tab/agentusage.tsx @@ -81,29 +81,51 @@ const UsageBar = memo(({ used, limit }: { used: number; limit: number }) => { }); UsageBar.displayName = "UsageBar"; -const WindowRow = memo(({ label, win, limit }: { label: string; win: AgentUsageWindow; limit: number }) => { - const pct = limit > 0 ? Math.round((win.total / limit) * 100) : null; - const remaining = formatRemaining(win.resetat, Date.now()); - return ( -
-
- - {label} - {remaining != null && ({remaining} until reset)} - - - {formatTokens(win.total)} - {limit > 0 ? ` / ${formatTokens(limit)} (${pct}%)` : ""} - -
- {limit > 0 && } -
- in {formatTokens(win.input)} · out {formatTokens(win.output)} · cache write{" "} - {formatTokens(win.cachewrite)} · cache read {formatTokens(win.cacheread)} · {win.messages} msgs +const WindowRow = memo( + ({ + label, + win, + limit = 0, + showReset = true, + }: { + label: string; + win: AgentUsageWindow; + limit?: number; + showReset?: boolean; + }) => { + const pct = limit > 0 ? Math.round((win.total / limit) * 100) : null; + const remaining = showReset ? formatRemaining(win.resetat, Date.now()) : null; + return ( +
+
+ + {label} + {remaining != null && ( + ({remaining} until reset) + )} + + + {formatTokens(win.total)} + {win.planpct != null + ? ` · ${Math.round(win.planpct)}% of plan` + : limit > 0 + ? ` / ${formatTokens(limit)} (${pct}%)` + : ""} + +
+ {win.planpct != null ? ( + + ) : ( + limit > 0 && + )} +
+ in {formatTokens(win.input)} · out {formatTokens(win.output)} · cache write{" "} + {formatTokens(win.cachewrite)} · cache read {formatTokens(win.cacheread)} · {win.messages} msgs +
-
- ); -}); + ); + } +); WindowRow.displayName = "WindowRow"; const LimitInput = memo( @@ -148,7 +170,6 @@ LimitInput.displayName = "LimitInput"; const AgentUsageWidgetComponent = () => { const agent = useAtomValue(getSettingsKeyAtom("agentusage:agent")) ?? "claude"; const conn = useAtomValue(getSettingsKeyAtom("agentusage:conn")) ?? ""; - const dailyLimit = useAtomValue(getSettingsKeyAtom("agentusage:dailylimit")) ?? 0; const weeklyLimit = useAtomValue(getSettingsKeyAtom("agentusage:weeklylimit")) ?? 0; const sessionLimit = useAtomValue(getSettingsKeyAtom("agentusage:sessionlimit")) ?? 0; const sessionHours = useAtomValue(getSettingsKeyAtom("agentusage:sessionhours")) ?? 5; @@ -168,7 +189,7 @@ const AgentUsageWidgetComponent = () => { useEffect(() => { setUsage(null); setError(null); - if (agent === "") { + if (agent === "off") { return; } let cancelled = false; @@ -177,7 +198,7 @@ const AgentUsageWidgetComponent = () => { const data = await RpcApi.AgentUsageCommand( TabRpcClient, { agent, conn: conn === "" ? undefined : conn, sessionhours: sessionHours }, - { timeout: 30000 } + { timeout: 90000 } ); if (cancelled) { return; @@ -214,7 +235,7 @@ const AgentUsageWidgetComponent = () => { const connOptions = conn !== "" && !connList.includes(conn) ? [conn, ...connList] : connList; let meter: React.ReactNode; - if (agent === "") { + if (agent === "off") { meter = ; } else if (error != null) { meter = ; @@ -230,10 +251,12 @@ const AgentUsageWidgetComponent = () => { <> {segments.map((seg, i) => { const remaining = formatRemaining(seg.win.resetat, now); - const value = - seg.limit > 0 - ? `${Math.round((seg.win.total / seg.limit) * 100)}%` - : formatTokens(seg.win.total); + let value = formatTokens(seg.win.total); + if (seg.win.planpct != null) { + value = `${Math.round(seg.win.planpct)}%`; + } else if (seg.limit > 0) { + value = `${Math.round((seg.win.total / seg.limit) * 100)}%`; + } return ( {i > 0 && · } @@ -255,7 +278,7 @@ const AgentUsageWidgetComponent = () => { {...getReferenceProps()} className="flex items-center gap-1.5 px-2 mb-1 h-[22px] text-xs rounded-sm cursor-pointer hover:bg-hover transition-colors" style={{ WebkitAppRegion: "no-drag" } as any} - title={agent === "" ? "AI agent usage" : `${AgentLabels[agent] ?? agent} usage`} + title={agent === "off" ? "AI agent usage" : `${AgentLabels[agent] ?? agent} usage`} > {meter}
@@ -274,7 +297,7 @@ const AgentUsageWidgetComponent = () => { win={usage.session} limit={sessionLimit} /> - + )} @@ -285,8 +308,9 @@ const AgentUsageWidgetComponent = () => { )} {error != null &&
{error}
}
- Counts input + output + cache-write tokens read from the agent's local logs; the plan's real - limits aren't in them, so set your own budgets below. + Counts input + output + cache-write tokens read from the agent's local logs. For Claude + Code, plan percentages and reset times come from ~/.claude/rate-limits.json when its + statusline script saves them (see the docs); otherwise the budgets below are used.
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a6ce8a03eb..75dd73c626 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -99,6 +99,7 @@ declare global { messages: number; firstts: number; resetat: number; + planpct?: number; }; // wshrpc.AiMessageData @@ -1738,7 +1739,6 @@ declare global { "agentusage:conn"?: string; "agentusage:sessionhours"?: number; "agentusage:sessionlimit"?: number; - "agentusage:dailylimit"?: number; "agentusage:weeklylimit"?: number; "tab:confirmcloserunning"?: boolean; "widget:*"?: boolean; diff --git a/pkg/agentusage/agentusage.go b/pkg/agentusage/agentusage.go index 0c27df5082..1152c18256 100644 --- a/pkg/agentusage/agentusage.go +++ b/pkg/agentusage/agentusage.go @@ -12,8 +12,10 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "sort" + "sync" "time" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -71,58 +73,179 @@ type codexLine struct { } `json:"payload"` } +// FileSource is where the agent logs live: the local disk, or a remote host reached over SFTP +// when wsh is not installed there. +type FileSource interface { + Home() string + Exists(path string) bool + Walk(ctx context.Context, root string, fn func(path string, size int64, mod time.Time)) error + Open(path string) (io.ReadCloser, error) +} + +type cacheEntry struct { + size int64 + mod time.Time + events []usageEvent +} + +// FileCache remembers the parsed events of each log file so a poll only re-reads files that changed. +type FileCache struct { + lock sync.Mutex + entries map[string]cacheEntry +} + +var ( + hostCachesLock sync.Mutex + hostCaches = map[string]*FileCache{} +) + +func MakeFileCache() *FileCache { + return &FileCache{entries: map[string]cacheEntry{}} +} + +func CacheForHost(host string) *FileCache { + hostCachesLock.Lock() + defer hostCachesLock.Unlock() + c := hostCaches[host] + if c == nil { + c = MakeFileCache() + hostCaches[host] = c + } + return c +} + +func (c *FileCache) get(path string, size int64, mod time.Time) ([]usageEvent, bool) { + if c == nil { + return nil, false + } + c.lock.Lock() + defer c.lock.Unlock() + e, ok := c.entries[path] + if !ok || e.size != size || !e.mod.Equal(mod) { + return nil, false + } + return e.events, true +} + +func (c *FileCache) put(path string, size int64, mod time.Time, events []usageEvent) { + if c == nil { + return + } + c.lock.Lock() + defer c.lock.Unlock() + c.entries[path] = cacheEntry{size: size, mod: mod, events: events} +} + +func (c *FileCache) keepOnly(seen map[string]bool) { + if c == nil { + return + } + c.lock.Lock() + defer c.lock.Unlock() + for p := range c.entries { + if !seen[p] { + delete(c.entries, p) + } + } +} + +type localSource struct{ home string } + +func (l localSource) Home() string { return l.home } + +func (l localSource) Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (l localSource) Walk(ctx context.Context, root string, fn func(path string, size int64, mod time.Time)) error { + return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil || d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + fn(p, info.Size(), info.ModTime()) + return nil + }) +} + +func (l localSource) Open(path string) (io.ReadCloser, error) { return os.Open(path) } + func Collect(ctx context.Context, data wshrpc.CommandAgentUsageData) (*wshrpc.AgentUsageData, error) { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("finding home directory: %w", err) } + return CollectFrom(ctx, data, localSource{home: home}, nil) +} + +// CollectFrom scans the agent's logs through src. cache may be nil. +func CollectFrom(ctx context.Context, data wshrpc.CommandAgentUsageData, src FileSource, cache *FileCache) (*wshrpc.AgentUsageData, error) { now := time.Now() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) weekWindow := WeekDays * 24 * time.Hour weekStart := now.Add(-weekWindow) var root string - var scan func(ctx context.Context, path string, since time.Time, emit func(usageEvent)) error + var scan func(r io.Reader) []usageEvent switch data.Agent { case wshrpc.AgentUsage_Claude: - root = filepath.Join(home, ".claude", "projects") - scan = scanClaudeFile + root = path.Join(filepath.ToSlash(src.Home()), ".claude", "projects") + scan = scanClaude case wshrpc.AgentUsage_Codex: - root = filepath.Join(home, ".codex", "sessions") - scan = scanCodexFile + root = path.Join(filepath.ToSlash(src.Home()), ".codex", "sessions") + scan = scanCodex default: return nil, fmt.Errorf("unknown agent %q", data.Agent) } + if _, isLocal := src.(localSource); isLocal { + root = filepath.FromSlash(root) + } rtn := &wshrpc.AgentUsageData{Agent: data.Agent} - if _, err := os.Stat(root); err != nil { + if !src.Exists(root) { return rtn, nil } rtn.Available = true var events []usageEvent - emit := func(ev usageEvent) { - events = append(events, ev) - addToWindow(&rtn.Week, ev) - if !ev.ts.Before(dayStart) { - addToWindow(&rtn.Today, ev) - } - } - walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if ctx.Err() != nil { - return ctx.Err() - } - if err != nil || d.IsDir() || filepath.Ext(path) != ".jsonl" { - return nil + seen := map[string]bool{} + walkErr := src.Walk(ctx, root, func(p string, size int64, mod time.Time) { + if filepath.Ext(p) != ".jsonl" || mod.Before(weekStart) || ctx.Err() != nil { + return } - info, err := d.Info() - if err != nil || info.ModTime().Before(weekStart) { - return nil + seen[p] = true + evs, ok := cache.get(p, size, mod) + if !ok { + // an unreadable or half-written log must not hide the totals from the others + f, err := src.Open(p) + if err != nil { + return + } + evs = scan(f) + f.Close() + cache.put(p, size, mod, evs) } - // an unreadable or half-written log must not hide the totals from the others - _ = scan(ctx, path, weekStart, emit) - return nil + events = append(events, evs...) }) if walkErr != nil { return nil, walkErr } + cache.keepOnly(seen) + kept := make([]usageEvent, 0, len(events)) + for _, ev := range events { + if ev.ts.Before(weekStart) { + continue + } + kept = append(kept, ev) + addToWindow(&rtn.Week, ev) + if !ev.ts.Before(dayStart) { + addToWindow(&rtn.Today, ev) + } + } rtn.Today.ResetAt = dayStart.AddDate(0, 0, 1).UnixMilli() if rtn.Week.FirstTs != 0 { rtn.Week.ResetAt = rtn.Week.FirstTs + weekWindow.Milliseconds() @@ -131,7 +254,10 @@ func Collect(ctx context.Context, data wshrpc.CommandAgentUsageData) (*wshrpc.Ag if sessionHours <= 0 { sessionHours = DefaultSessionHours } - rtn.Session = currentSession(events, now, time.Duration(sessionHours)*time.Hour) + rtn.Session = currentSession(kept, now, time.Duration(sessionHours)*time.Hour) + if data.Agent == wshrpc.AgentUsage_Claude { + applyPlanLimits(rtn, src, now) + } return rtn, nil } @@ -168,39 +294,31 @@ func addToWindow(w *wshrpc.AgentUsageWindow, ev usageEvent) { } } -func forEachLine(path string, marker []byte, fn func(line []byte)) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() +func forEachLine(r io.Reader, marker []byte, fn func(line []byte)) { // bufio.Scanner would give up on the multi-megabyte lines agents write for tool results - r := bufio.NewReaderSize(f, 256*1024) + br := bufio.NewReaderSize(r, 256*1024) for { - line, err := r.ReadBytes('\n') + line, err := br.ReadBytes('\n') if len(line) > 0 && bytes.Contains(line, marker) { fn(line) } - if err == io.EOF { - return nil - } if err != nil { - return err + return } } } -func scanClaudeFile(ctx context.Context, path string, since time.Time, emit func(usageEvent)) error { +func scanClaude(r io.Reader) []usageEvent { // a streamed response is logged once per content block with the same message and request id, so only the last counts last := map[string]usageEvent{} order := []string{} - err := forEachLine(path, usageMarker, func(line []byte) { + forEachLine(r, usageMarker, func(line []byte) { var cl claudeLine if json.Unmarshal(line, &cl) != nil || cl.Type != "assistant" { return } ts, err := time.Parse(time.RFC3339Nano, cl.Timestamp) - if err != nil || ts.Before(since) { + if err != nil { return } u := cl.Message.Usage @@ -213,16 +331,18 @@ func scanClaudeFile(ctx context.Context, path string, since time.Time, emit func } last[key] = usageEvent{ts: ts, input: u.Input, output: u.Output, cacheWrite: u.CacheWrite, cacheRead: u.CacheRead} }) + events := make([]usageEvent, 0, len(order)) for _, key := range order { - emit(last[key]) + events = append(events, last[key]) } - return err + return events } -func scanCodexFile(ctx context.Context, path string, since time.Time, emit func(usageEvent)) error { +func scanCodex(r io.Reader) []usageEvent { // Codex repeats token_count events without new usage, so only a changed running total counts var prevTotal int64 - return forEachLine(path, tokenMarker, func(line []byte) { + var events []usageEvent + forEachLine(r, tokenMarker, func(line []byte) { var cl codexLine if json.Unmarshal(line, &cl) != nil || cl.Type != "event_msg" || cl.Payload.Type != "token_count" || cl.Payload.Info == nil { return @@ -233,14 +353,15 @@ func scanCodexFile(ctx context.Context, path string, since time.Time, emit func( } prevTotal = info.Total.Total ts, err := time.Parse(time.RFC3339Nano, cl.Timestamp) - if err != nil || ts.Before(since) { + if err != nil { return } - emit(usageEvent{ + events = append(events, usageEvent{ ts: ts, input: info.Last.Input - info.Last.CachedInput, output: info.Last.Output, cacheRead: info.Last.CachedInput, }) }) + return events } diff --git a/pkg/agentusage/agentusage_test.go b/pkg/agentusage/agentusage_test.go index fdee42b1c6..bca5003847 100644 --- a/pkg/agentusage/agentusage_test.go +++ b/pkg/agentusage/agentusage_test.go @@ -6,8 +6,11 @@ package agentusage import ( "context" "fmt" + "io" + "io/fs" "os" "path/filepath" + "strings" "testing" "time" @@ -95,3 +98,123 @@ func TestCollectMissingAndUnknown(t *testing.T) { t.Errorf("expected error for unknown agent") } } + +type memFile struct { + data string + mod time.Time +} + +type memSource struct { + home string + files map[string]memFile + opens int +} + +func (m *memSource) Home() string { return m.home } + +func (m *memSource) Exists(p string) bool { + for name := range m.files { + if strings.HasPrefix(name, p+"/") { + return true + } + } + return false +} + +func (m *memSource) Walk(ctx context.Context, root string, fn func(string, int64, time.Time)) error { + for name, f := range m.files { + if strings.HasPrefix(name, root+"/") { + fn(name, int64(len(f.data)), f.mod) + } + } + return nil +} + +func (m *memSource) Open(p string) (io.ReadCloser, error) { + f, ok := m.files[p] + if !ok { + return nil, fs.ErrNotExist + } + m.opens++ + return io.NopCloser(strings.NewReader(f.data)), nil +} + +func TestCollectFromRemoteSourceCaches(t *testing.T) { + now := time.Now() + const logPath = "/srv/example/.claude/projects/p/a.jsonl" + src := &memSource{home: "/srv/example", files: map[string]memFile{ + logPath: {claudeRow(now.Add(-time.Minute), "a", 1, 10, 100, 0), now}, + "/srv/example/.claude/projects/p/old.jsonl": {claudeRow(now.AddDate(0, 0, -30), "o", 9, 9, 9, 9), now.AddDate(0, 0, -30)}, + "/srv/example/.claude/projects/p/notes.txt": {"ignored", now}, + }} + cache := MakeFileCache() + data := wshrpc.CommandAgentUsageData{Agent: wshrpc.AgentUsage_Claude} + got, err := CollectFrom(context.Background(), data, src, cache) + if err != nil { + t.Fatal(err) + } + if !got.Available || got.Week.Total != 111 || got.Today.Messages != 1 || got.Session.Total != 111 { + t.Fatalf("got %+v", got) + } + if src.opens != 1 { + t.Errorf("opens = %d, want 1 (old and non-jsonl files must be skipped)", src.opens) + } + if _, err := CollectFrom(context.Background(), data, src, cache); err != nil { + t.Fatal(err) + } + if src.opens != 1 { + t.Errorf("unchanged file was re-read: opens = %d", src.opens) + } + f := src.files[logPath] + f.data += claudeRow(now, "b", 0, 5, 0, 0) + src.files[logPath] = f + got, err = CollectFrom(context.Background(), data, src, cache) + if err != nil { + t.Fatal(err) + } + if src.opens != 2 || got.Week.Total != 116 { + t.Errorf("changed file: opens = %d, week = %+v", src.opens, got.Week) + } + delete(src.files, logPath) + if _, err := CollectFrom(context.Background(), data, src, cache); err != nil { + t.Fatal(err) + } + if len(cache.entries) != 0 { + t.Errorf("cache kept entries for deleted files: %d", len(cache.entries)) + } +} + +func TestScanCodex(t *testing.T) { + ts := time.Now().UTC().Format(time.RFC3339Nano) + row := func(total, in, cached, out int64) string { + return fmt.Sprintf(`{"type":"event_msg","timestamp":%q,"payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":%d},"last_token_usage":{"input_tokens":%d,"cached_input_tokens":%d,"output_tokens":%d}}}}`+"\n", ts, total, in, cached, out) + } + evs := scanCodex(strings.NewReader(row(100, 90, 40, 10) + row(100, 90, 40, 10) + row(250, 120, 20, 30))) + if len(evs) != 2 || evs[0].input != 50 || evs[0].cacheRead != 40 || evs[1].output != 30 { + t.Errorf("events = %+v", evs) + } +} + +func TestCollectFromPlanLimits(t *testing.T) { + now := time.Now() + fiveReset := now.Add(4 * time.Hour).Unix() + staleReset := now.Add(-time.Hour).Unix() + limits := fmt.Sprintf(`{"five_hour":{"used_percentage":2.4,"resets_at":%d},"seven_day":{"used_percentage":19,"resets_at":%d}}`, fiveReset, staleReset) + src := &memSource{home: "/srv/example", files: map[string]memFile{ + "/srv/example/.claude/projects/p/a.jsonl": {claudeRow(now.Add(-time.Minute), "a", 1, 10, 100, 0), now}, + "/srv/example/.claude/rate-limits.json": {limits, now}, + }} + got, err := CollectFrom(context.Background(), wshrpc.CommandAgentUsageData{Agent: wshrpc.AgentUsage_Claude}, src, nil) + if err != nil { + t.Fatal(err) + } + if got.Session.PlanPct == nil || *got.Session.PlanPct != 2.4 || got.Session.ResetAt != fiveReset*1000 { + t.Errorf("session = %+v", got.Session) + } + if got.Week.PlanPct != nil { + t.Errorf("a window past its reset must fall back to the estimate: %+v", got.Week) + } + if got.Week.Total != 111 { + t.Errorf("week total = %d", got.Week.Total) + } +} diff --git a/pkg/agentusage/planlimits.go b/pkg/agentusage/planlimits.go new file mode 100644 index 0000000000..7efc556448 --- /dev/null +++ b/pkg/agentusage/planlimits.go @@ -0,0 +1,75 @@ +// Copyright 2026, Atreus-X (fork of Wave Terminal by Command Line Inc.) +// SPDX-License-Identifier: Apache-2.0 + +package agentusage + +import ( + "encoding/json" + "io" + "path" + "path/filepath" + "time" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const PlanLimitsFile = "rate-limits.json" + +const maxPlanLimitsBytes = 64 * 1024 + +type planWindow struct { + UsedPercentage *float64 `json:"used_percentage"` + ResetsAt int64 `json:"resets_at"` +} + +// planLimits is the rate_limits object Claude Code hands to the statusline command; the +// statusline script saves it because the server-side percentages are not in the session logs. +type planLimits struct { + FiveHour planWindow `json:"five_hour"` + SevenDay planWindow `json:"seven_day"` +} + +func readPlanLimits(src FileSource) *planLimits { + p := path.Join(filepath.ToSlash(src.Home()), ".claude", PlanLimitsFile) + if _, isLocal := src.(localSource); isLocal { + p = filepath.FromSlash(p) + } + f, err := src.Open(p) + if err != nil { + return nil + } + defer f.Close() + buf, err := io.ReadAll(io.LimitReader(f, maxPlanLimitsBytes)) + if err != nil { + return nil + } + var pl planLimits + if json.Unmarshal(buf, &pl) != nil { + return nil + } + return &pl +} + +// applyPlanWindow overrides a window's estimate with the plan's own figures. A window whose reset +// time has passed is skipped: the saved percentage describes the previous window. +func applyPlanWindow(win *wshrpc.AgentUsageWindow, pw planWindow, now time.Time) { + if pw.UsedPercentage == nil || pw.ResetsAt <= 0 { + return + } + resetMs := pw.ResetsAt * 1000 + if resetMs <= now.UnixMilli() { + return + } + pct := *pw.UsedPercentage + win.PlanPct = &pct + win.ResetAt = resetMs +} + +func applyPlanLimits(rtn *wshrpc.AgentUsageData, src FileSource, now time.Time) { + pl := readPlanLimits(src) + if pl == nil { + return + } + applyPlanWindow(&rtn.Session, pl.FiveHour, now) + applyPlanWindow(&rtn.Week, pl.SevenDay, now) +} diff --git a/pkg/remote/fileshare/sftpfs/agentusage.go b/pkg/remote/fileshare/sftpfs/agentusage.go new file mode 100644 index 0000000000..78b4a45e68 --- /dev/null +++ b/pkg/remote/fileshare/sftpfs/agentusage.go @@ -0,0 +1,82 @@ +// Copyright 2026, Atreus-X (fork of Wave Terminal by Command Line Inc.) +// SPDX-License-Identifier: Apache-2.0 + +package sftpfs + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/wavetermdev/waveterm/pkg/agentusage" + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" +) + +type usageSource struct { + host string + sess *session +} + +func (u *usageSource) Home() string { return u.sess.home } + +func (u *usageSource) Exists(path string) bool { + _, err := u.sess.client.Stat(path) + return err == nil +} + +func (u *usageSource) Walk(ctx context.Context, root string, fn func(path string, size int64, mod time.Time)) error { + w := u.sess.client.Walk(root) + for w.Step() { + if ctx.Err() != nil { + return ctx.Err() + } + if w.Err() != nil || w.Stat() == nil || w.Stat().IsDir() { + continue + } + fn(w.Path(), w.Stat().Size(), w.Stat().ModTime()) + } + return nil +} + +func (u *usageSource) Open(path string) (io.ReadCloser, error) { + f, err := u.sess.client.Open(path) + if err != nil { + maybeDropSession(u.host, err) + return nil, err + } + return f, nil +} + +// AgentUsageSource returns an SFTP view of the host's home directory for reading agent logs +// without wsh. The connection must already be established. +func AgentUsageSource(host string) (agentusage.FileSource, error) { + if conncontroller.IsLocalConnName(host) || conncontroller.IsWslConnName(host) { + return nil, fmt.Errorf("SFTP is not available for %q", host) + } + opts, err := remote.ParseOpts(host) + if err != nil { + return nil, err + } + conn := conncontroller.MaybeGetConn(opts) + if conn == nil { + return nil, fmt.Errorf("connection %s is not connected", host) + } + sess, err := getSession(host, conn) + if err != nil { + return nil, err + } + return &usageSource{host: host, sess: sess}, nil +} + +// ConnHasWsh reports whether the connection is known to be running wsh. Unknown connections +// count as wsh so the normal route is tried first. +func ConnHasWsh(host string) bool { + opts, err := remote.ParseOpts(host) + if err != nil { + return true + } + conn := conncontroller.MaybeGetConn(opts) + return conn == nil || conn.WshEnabled.Load() +} diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 8adf8e7ade..fa51d38fe7 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -103,7 +103,6 @@ const ( ConfigKey_AgentUsageConn = "agentusage:conn" ConfigKey_AgentUsageSessionHours = "agentusage:sessionhours" ConfigKey_AgentUsageSessionLimit = "agentusage:sessionlimit" - ConfigKey_AgentUsageDailyLimit = "agentusage:dailylimit" ConfigKey_AgentUsageWeeklyLimit = "agentusage:weeklylimit" ConfigKey_TabConfirmCloseRunning = "tab:confirmcloserunning" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 00dbfe7e2e..cd824e2797 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -151,7 +151,7 @@ type SettingsType struct { // Library block tab order, "snippets,notes" (default) or "notes,snippets"; the first opens by default LibraryTabOrder string `json:"library:taborder,omitempty" jsonschema:"enum=snippets\\,notes,enum=notes\\,snippets"` // AI agent whose usage the tab bar meter shows: "claude" or "codex"; empty hides the meter - AgentUsageAgent string `json:"agentusage:agent,omitempty" jsonschema:"enum=claude,enum=codex"` + AgentUsageAgent string `json:"agentusage:agent,omitempty" jsonschema:"enum=claude,enum=codex,enum=off"` // connection the agent runs on ("wsh"/SSH name, e.g. "user@host"); empty means this machine AgentUsageConn string `json:"agentusage:conn,omitempty"` // length in hours of the plan's rolling usage session (default 5, as on Claude Pro/Max) @@ -159,7 +159,6 @@ type SettingsType struct { // token budget for one session; 0 shows raw usage without a bar AgentUsageSessionLimit int64 `json:"agentusage:sessionlimit,omitempty"` // token budget for the meter's "today" bar; 0 shows raw usage without a bar - AgentUsageDailyLimit int64 `json:"agentusage:dailylimit,omitempty"` // token budget for the meter's "last 7 days" bar; 0 shows raw usage without a bar AgentUsageWeeklyLimit int64 `json:"agentusage:weeklylimit,omitempty"` // warn before closing a tab whose terminals are running something (default true) diff --git a/pkg/wshrpc/wshrpctypes_agentusage.go b/pkg/wshrpc/wshrpctypes_agentusage.go index 0b0d4b8a70..e810e10959 100644 --- a/pkg/wshrpc/wshrpctypes_agentusage.go +++ b/pkg/wshrpc/wshrpctypes_agentusage.go @@ -28,13 +28,15 @@ type AgentUsageWindow struct { FirstTs int64 `json:"firstts"` // unix millis when the window next frees capacity: next local midnight for today, the oldest message aging out for the week; 0 when there is no usage to expire ResetAt int64 `json:"resetat"` + // percent of the plan limit used, as reported by the agent itself; nil when unavailable, in which case resetat is only an estimate + PlanPct *float64 `json:"planpct,omitempty"` } type AgentUsageData struct { - Agent string `json:"agent"` - Available bool `json:"available"` + Agent string `json:"agent"` + Available bool `json:"available"` // the currently open plan session; empty (resetat 0) when no session is active - Session AgentUsageWindow `json:"session"` - Today AgentUsageWindow `json:"today"` - Week AgentUsageWindow `json:"week"` + Session AgentUsageWindow `json:"session"` + Today AgentUsageWindow `json:"today"` + Week AgentUsageWindow `json:"week"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index bef5ab6299..2b1ff3800d 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -21,6 +21,7 @@ import ( "time" "github.com/skratchdot/open-golang/open" + "github.com/wavetermdev/waveterm/pkg/agentusage" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" @@ -33,11 +34,11 @@ import ( "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/hostinfo" "github.com/wavetermdev/waveterm/pkg/jobcontroller" - "github.com/wavetermdev/waveterm/pkg/agentusage" "github.com/wavetermdev/waveterm/pkg/library" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/remote/fileshare/sftpfs" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/suggestion" @@ -64,7 +65,10 @@ import ( "github.com/wavetermdev/waveterm/tsunami/build" ) -var InvalidWslDistroNames = []string{"docker-desktop", "docker-desktop-data"} +// kept well under the frontend's timeout so a dead wsh route still leaves time for the SFTP fallback +const AgentUsageWshTimeoutMs = 8000 + +var InvalidWslDistroNames =[]string{"docker-desktop", "docker-desktop-data"} type WshServer struct{} @@ -624,7 +628,23 @@ func (ws *WshServer) AgentUsageCommand(ctx context.Context, data wshrpc.CommandA if err := conncontroller.EnsureConnection(ctx, data.Conn); err != nil { return nil, fmt.Errorf("connecting to %q: %w", data.Conn, err) } - return wshclient.RemoteAgentUsageCommand(wshfs.RpcClient, data, &wshrpc.RpcOpts{Timeout: 30000, Route: wshutil.MakeConnectionRouteId(data.Conn)}) + var wshErr error + if sftpfs.ConnHasWsh(data.Conn) { + rtn, err := wshclient.RemoteAgentUsageCommand(wshfs.RpcClient, data, &wshrpc.RpcOpts{Timeout: AgentUsageWshTimeoutMs, Route: wshutil.MakeConnectionRouteId(data.Conn)}) + if err == nil { + return rtn, nil + } + wshErr = err + } + // hosts without wsh (or where its route failed) are read over plain SFTP + src, err := sftpfs.AgentUsageSource(data.Conn) + if err != nil { + if wshErr != nil { + return nil, wshErr + } + return nil, err + } + return agentusage.CollectFrom(ctx, data, src, agentusage.CacheForHost(data.Conn)) } func (ws *WshServer) LibraryReadCommand(ctx context.Context) (*wshrpc.LibraryData, error) { diff --git a/schema/settings.json b/schema/settings.json index ffbadbd894..df7dbfb95d 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -284,7 +284,8 @@ "type": "string", "enum": [ "claude", - "codex" + "codex", + "off" ] }, "agentusage:conn": { @@ -296,9 +297,6 @@ "agentusage:sessionlimit": { "type": "integer" }, - "agentusage:dailylimit": { - "type": "integer" - }, "agentusage:weeklylimit": { "type": "integer" },