+
0 && bytes.Contains(line, marker) {
+ fn(line)
+ }
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+}
+
+func scanClaudeFile(ctx context.Context, path string, since time.Time, emit func(usageEvent)) error {
+ // 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) {
+ 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) {
+ return
+ }
+ u := cl.Message.Usage
+ key := cl.Message.Id + "|" + cl.RequestId
+ if cl.Message.Id == "" {
+ key = cl.Timestamp
+ }
+ if _, ok := last[key]; !ok {
+ order = append(order, key)
+ }
+ last[key] = usageEvent{ts: ts, input: u.Input, output: u.Output, cacheWrite: u.CacheWrite, cacheRead: u.CacheRead}
+ })
+ for _, key := range order {
+ emit(last[key])
+ }
+ return err
+}
+
+func scanCodexFile(ctx context.Context, path string, since time.Time, emit func(usageEvent)) error {
+ // 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 cl codexLine
+ if json.Unmarshal(line, &cl) != nil || cl.Type != "event_msg" || cl.Payload.Type != "token_count" || cl.Payload.Info == nil {
+ return
+ }
+ info := cl.Payload.Info
+ if info.Total.Total == prevTotal {
+ return
+ }
+ prevTotal = info.Total.Total
+ ts, err := time.Parse(time.RFC3339Nano, cl.Timestamp)
+ if err != nil || ts.Before(since) {
+ return
+ }
+ emit(usageEvent{
+ ts: ts,
+ input: info.Last.Input - info.Last.CachedInput,
+ output: info.Last.Output,
+ cacheRead: info.Last.CachedInput,
+ })
+ })
+}
diff --git a/pkg/agentusage/agentusage_test.go b/pkg/agentusage/agentusage_test.go
new file mode 100644
index 0000000000..fdee42b1c6
--- /dev/null
+++ b/pkg/agentusage/agentusage_test.go
@@ -0,0 +1,97 @@
+// Copyright 2026, Atreus-X (fork of Wave Terminal by Command Line Inc.)
+// SPDX-License-Identifier: Apache-2.0
+
+package agentusage
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+func claudeRow(ts time.Time, msgId string, in, out, cw, cr int64) string {
+ return fmt.Sprintf(`{"type":"assistant","timestamp":%q,"requestId":"r-%s","message":{"id":%q,"usage":{"input_tokens":%d,"output_tokens":%d,"cache_creation_input_tokens":%d,"cache_read_input_tokens":%d}}}`+"\n",
+ ts.UTC().Format(time.RFC3339Nano), msgId, msgId, in, out, cw, cr)
+}
+
+func TestCollectClaude(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home)
+ dir := filepath.Join(home, ".claude", "projects", "proj")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ content := claudeRow(now.Add(-time.Minute), "a", 1, 10, 100, 5000) +
+ claudeRow(now.Add(-time.Minute), "a", 2, 20, 100, 5000) + // streamed duplicate of "a": last one wins
+ claudeRow(now.AddDate(0, 0, -3), "b", 3, 30, 0, 0) +
+ claudeRow(now.AddDate(0, 0, -30), "c", 999, 999, 999, 999) +
+ "not json but mentions \"usage\"\n"
+ if err := os.WriteFile(filepath.Join(dir, "s.jsonl"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Collect(context.Background(), wshrpc.CommandAgentUsageData{Agent: wshrpc.AgentUsage_Claude})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Available {
+ t.Fatalf("expected available")
+ }
+ if got.Today.Total != 2+20+100 || got.Today.Messages != 1 || got.Today.CacheRead != 5000 {
+ t.Errorf("today = %+v", got.Today)
+ }
+ if got.Week.Total != 2+20+100+3+30 || got.Week.Messages != 2 {
+ t.Errorf("week = %+v", got.Week)
+ }
+ if got.Today.ResetAt <= now.UnixMilli() || got.Today.ResetAt > now.Add(24*time.Hour).UnixMilli() {
+ t.Errorf("today resetat = %d", got.Today.ResetAt)
+ }
+ if want := got.Week.FirstTs + (WeekDays * 24 * time.Hour).Milliseconds(); got.Week.ResetAt != want {
+ t.Errorf("week resetat = %d, want %d", got.Week.ResetAt, want)
+ }
+}
+
+func TestCurrentSession(t *testing.T) {
+ base := time.Date(2026, 1, 1, 10, 20, 0, 0, time.UTC)
+ span := 5 * time.Hour
+ ev := func(ts time.Time, out int64) usageEvent { return usageEvent{ts: ts, output: out} }
+ events := []usageEvent{
+ ev(base.Add(3*time.Hour), 7), // out of order on purpose
+ ev(base, 10),
+ ev(base.Add(30*time.Minute), 5),
+ ev(base.Add(6*time.Hour), 100), // past 15:00 end: opens a second session ending 21:00
+ }
+ got := currentSession(events, base.Add(7*time.Hour), span)
+ if got.Total != 100 || got.Messages != 1 {
+ t.Errorf("second session = %+v", got)
+ }
+ if want := time.Date(2026, 1, 1, 21, 0, 0, 0, time.UTC).UnixMilli(); got.ResetAt != want {
+ t.Errorf("resetat = %d, want %d", got.ResetAt, want)
+ }
+ first := currentSession(events[:3], base.Add(4*time.Hour), span)
+ if first.Total != 22 || first.Messages != 3 {
+ t.Errorf("first session = %+v", first)
+ }
+ if expired := currentSession(events[:3], base.Add(6*time.Hour), span); expired.ResetAt != 0 || expired.Total != 0 {
+ t.Errorf("expired session = %+v", expired)
+ }
+}
+
+func TestCollectMissingAndUnknown(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home)
+ got, err := Collect(context.Background(), wshrpc.CommandAgentUsageData{Agent: wshrpc.AgentUsage_Codex})
+ if err != nil || got.Available {
+ t.Errorf("missing dir: got=%+v err=%v", got, err)
+ }
+ if _, err := Collect(context.Background(), wshrpc.CommandAgentUsageData{Agent: "nope"}); err == nil {
+ t.Errorf("expected error for unknown agent")
+ }
+}
diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go
index 639748173c..8adf8e7ade 100644
--- a/pkg/wconfig/metaconsts.go
+++ b/pkg/wconfig/metaconsts.go
@@ -99,6 +99,13 @@ const (
ConfigKey_LibraryTabOrder = "library:taborder"
+ ConfigKey_AgentUsageAgent = "agentusage:agent"
+ ConfigKey_AgentUsageConn = "agentusage:conn"
+ ConfigKey_AgentUsageSessionHours = "agentusage:sessionhours"
+ ConfigKey_AgentUsageSessionLimit = "agentusage:sessionlimit"
+ ConfigKey_AgentUsageDailyLimit = "agentusage:dailylimit"
+ ConfigKey_AgentUsageWeeklyLimit = "agentusage:weeklylimit"
+
ConfigKey_TabConfirmCloseRunning = "tab:confirmcloserunning"
ConfigKey_WidgetClear = "widget:*"
diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go
index 6ab5d4edbb..00dbfe7e2e 100644
--- a/pkg/wconfig/settingsconfig.go
+++ b/pkg/wconfig/settingsconfig.go
@@ -150,6 +150,18 @@ 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"`
+ // 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)
+ AgentUsageSessionHours int64 `json:"agentusage:sessionhours,omitempty"`
+ // 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)
TabConfirmCloseRunning *bool `json:"tab:confirmcloserunning,omitempty"`
diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go
index 7a7783f56e..d1ce318db1 100644
--- a/pkg/wshrpc/wshclient/wshclient.go
+++ b/pkg/wshrpc/wshclient/wshclient.go
@@ -23,6 +23,12 @@ func ActivityCommand(w *wshutil.WshRpc, data wshrpc.ActivityUpdate, opts *wshrpc
return err
}
+// command "agentusage", wshserver.AgentUsageCommand
+func AgentUsageCommand(w *wshutil.WshRpc, data wshrpc.CommandAgentUsageData, opts *wshrpc.RpcOpts) (*wshrpc.AgentUsageData, error) {
+ resp, err := sendRpcRequestCallHelper[*wshrpc.AgentUsageData](w, "agentusage", data, opts)
+ return resp, err
+}
+
// command "aisendmessage", wshserver.AiSendMessageCommand
func AiSendMessageCommand(w *wshutil.WshRpc, data wshrpc.AiMessageData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "aisendmessage", data, opts)
@@ -736,6 +742,12 @@ func RecordTEventCommand(w *wshutil.WshRpc, data telemetrydata.TEvent, opts *wsh
return err
}
+// command "remoteagentusage", wshserver.RemoteAgentUsageCommand
+func RemoteAgentUsageCommand(w *wshutil.WshRpc, data wshrpc.CommandAgentUsageData, opts *wshrpc.RpcOpts) (*wshrpc.AgentUsageData, error) {
+ resp, err := sendRpcRequestCallHelper[*wshrpc.AgentUsageData](w, "remoteagentusage", data, opts)
+ return resp, err
+}
+
// command "remotedisconnectfromjobmanager", wshserver.RemoteDisconnectFromJobManagerCommand
func RemoteDisconnectFromJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteDisconnectFromJobManagerData, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "remotedisconnectfromjobmanager", data, opts)
diff --git a/pkg/wshrpc/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go
index 35dc92d5bb..761d30deca 100644
--- a/pkg/wshrpc/wshremote/wshremote.go
+++ b/pkg/wshrpc/wshremote/wshremote.go
@@ -14,6 +14,7 @@ import (
"sync"
"time"
+ "github.com/wavetermdev/waveterm/pkg/agentusage"
"github.com/wavetermdev/waveterm/pkg/baseds"
"github.com/wavetermdev/waveterm/pkg/panichandler"
"github.com/wavetermdev/waveterm/pkg/suggestion"
@@ -86,6 +87,10 @@ func (impl *ServerImpl) StreamTestCommand(ctx context.Context) chan wshrpc.RespO
return ch
}
+func (*ServerImpl) RemoteAgentUsageCommand(ctx context.Context, data wshrpc.CommandAgentUsageData) (*wshrpc.AgentUsageData, error) {
+ return agentusage.Collect(ctx, data)
+}
+
func (*ServerImpl) RemoteGetInfoCommand(ctx context.Context) (wshrpc.RemoteInfo, error) {
return wshutil.GetInfo(), nil
}
diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go
index d62df8f19c..bfdbf3022e 100644
--- a/pkg/wshrpc/wshrpctypes.go
+++ b/pkg/wshrpc/wshrpctypes.go
@@ -129,6 +129,9 @@ type WshRpcInterface interface {
LibraryNoteWriteCommand(ctx context.Context, data CommandLibraryNoteWriteData) (*LibraryNoteData, error)
LibraryNoteDeleteCommand(ctx context.Context, data CommandLibraryNoteRefData) error
+ // agent usage: token totals scanned from the local Claude Code / Codex session logs
+ AgentUsageCommand(ctx context.Context, data CommandAgentUsageData) (*AgentUsageData, error)
+
// eventrecv is special, it's handled internally by WshRpc with EventListener
EventRecvCommand(ctx context.Context, data wps.WaveEvent) error
@@ -136,6 +139,7 @@ type WshRpcInterface interface {
WshRpcRemoteFileInterface
RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]
RemoteGetInfoCommand(ctx context.Context) (RemoteInfo, error)
+ RemoteAgentUsageCommand(ctx context.Context, data CommandAgentUsageData) (*AgentUsageData, error)
RemoteInstallRcFilesCommand(ctx context.Context) error
RemoteStartJobCommand(ctx context.Context, data CommandRemoteStartJobData) (*CommandStartJobRtnData, error)
RemoteReconnectToJobManagerCommand(ctx context.Context, data CommandRemoteReconnectToJobManagerData) (*CommandRemoteReconnectToJobManagerRtnData, error)
diff --git a/pkg/wshrpc/wshrpctypes_agentusage.go b/pkg/wshrpc/wshrpctypes_agentusage.go
new file mode 100644
index 0000000000..0b0d4b8a70
--- /dev/null
+++ b/pkg/wshrpc/wshrpctypes_agentusage.go
@@ -0,0 +1,40 @@
+// Copyright 2026, Atreus-X (fork of Wave Terminal by Command Line Inc.)
+// SPDX-License-Identifier: Apache-2.0
+
+package wshrpc
+
+const (
+ AgentUsage_Claude = "claude"
+ AgentUsage_Codex = "codex"
+)
+
+type CommandAgentUsageData struct {
+ Agent string `json:"agent"`
+ // connection the agent runs on; empty or "local" scans this machine, anything else needs wsh on that host
+ Conn string `json:"conn,omitempty"`
+ // length of the plan's rolling session in hours (Claude Pro/Max use 5); 0 means the default
+ SessionHours int `json:"sessionhours,omitempty"`
+}
+
+type AgentUsageWindow struct {
+ Input int64 `json:"input"`
+ Output int64 `json:"output"`
+ CacheWrite int64 `json:"cachewrite"`
+ CacheRead int64 `json:"cacheread"`
+ // input + output + cachewrite; cache reads are excluded because they dominate agent logs without counting like fresh tokens
+ Total int64 `json:"total"`
+ Messages int `json:"messages"`
+ // unix millis of the oldest counted message, 0 when there is none
+ 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"`
+}
+
+type AgentUsageData struct {
+ 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"`
+}
diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go
index 25a15b5615..bef5ab6299 100644
--- a/pkg/wshrpc/wshserver/wshserver.go
+++ b/pkg/wshrpc/wshserver/wshserver.go
@@ -33,6 +33,7 @@ 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"
@@ -55,6 +56,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/wcore"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
"github.com/wavetermdev/waveterm/pkg/wsl"
"github.com/wavetermdev/waveterm/pkg/wslconn"
@@ -615,6 +617,16 @@ func (ws *WshServer) HostActionCommand(ctx context.Context, data wshrpc.CommandH
return hostinfo.RunAction(ctx, data)
}
+func (ws *WshServer) AgentUsageCommand(ctx context.Context, data wshrpc.CommandAgentUsageData) (*wshrpc.AgentUsageData, error) {
+ if data.Conn == "" || conncontroller.IsLocalConnName(data.Conn) {
+ return agentusage.Collect(ctx, data)
+ }
+ 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)})
+}
+
func (ws *WshServer) LibraryReadCommand(ctx context.Context) (*wshrpc.LibraryData, error) {
return library.ReadLibrary()
}
diff --git a/schema/settings.json b/schema/settings.json
index bfdd5b850a..ffbadbd894 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -280,6 +280,28 @@
"notes,snippets"
]
},
+ "agentusage:agent": {
+ "type": "string",
+ "enum": [
+ "claude",
+ "codex"
+ ]
+ },
+ "agentusage:conn": {
+ "type": "string"
+ },
+ "agentusage:sessionhours": {
+ "type": "integer"
+ },
+ "agentusage:sessionlimit": {
+ "type": "integer"
+ },
+ "agentusage:dailylimit": {
+ "type": "integer"
+ },
+ "agentusage:weeklylimit": {
+ "type": "integer"
+ },
"tab:confirmcloserunning": {
"type": "boolean"
},