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 (
-
- 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"
},