diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 2549e47051..7333433e8a 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -108,6 +108,12 @@ 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: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) | | widget:showhelp | bool | whether to show help/tips widgets in right sidebar | diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 4b8f34e75f..1625ee7438 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -24,6 +24,12 @@ export class RpcApiType { return client.wshRpcCall("activity", data, opts); } + // command "agentusage" [call] + AgentUsageCommand(client: WshClient, data: CommandAgentUsageData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "agentusage", data, opts); + return client.wshRpcCall("agentusage", data, opts); + } + // command "aisendmessage" [call] AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "aisendmessage", data, opts); @@ -738,6 +744,12 @@ export class RpcApiType { return client.wshRpcCall("recordtevent", data, opts); } + // command "remoteagentusage" [call] + RemoteAgentUsageCommand(client: WshClient, data: CommandAgentUsageData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteagentusage", data, opts); + return client.wshRpcCall("remoteagentusage", data, opts); + } + // command "remotedisconnectfromjobmanager" [call] RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotedisconnectfromjobmanager", data, opts); diff --git a/frontend/app/tab/agentusage.tsx b/frontend/app/tab/agentusage.tsx new file mode 100644 index 0000000000..e50b41a07f --- /dev/null +++ b/frontend/app/tab/agentusage.tsx @@ -0,0 +1,365 @@ +// Copyright 2026, Atreus-X (fork of Wave Terminal by Command Line Inc.) +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { getSettingsKeyAtom } from "@/store/global"; +import { cn } from "@/util/util"; +import { + autoUpdate, + FloatingPortal, + offset, + useClick, + useDismiss, + useFloating, + useInteractions, +} from "@floating-ui/react"; +import { useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useState } from "react"; + +const PollIntervalMs = 60000; +const AgentLabels: Record = { claude: "Claude Code", codex: "Codex" }; + +function formatTokens(n: number): string { + if (n >= 1e9) { + return (n / 1e9).toFixed(2) + "B"; + } + if (n >= 1e6) { + return (n / 1e6).toFixed(1) + "M"; + } + if (n >= 1e3) { + return (n / 1e3).toFixed(1) + "K"; + } + return String(n); +} + +function formatRemaining(resetAt: number, now: number): string { + if (resetAt <= 0) { + return null; + } + const mins = Math.max(0, Math.ceil((resetAt - now) / 60000)); + const days = Math.floor(mins / 1440); + const hours = Math.floor((mins % 1440) / 60); + if (days > 0) { + return `${days}d${hours}h`; + } + if (hours > 0) { + return `${hours}h${mins % 60}m`; + } + return `${mins}m`; +} + +function usageColor(ratio: number): string { + if (ratio >= 1) { + return "bg-red-500"; + } + if (ratio >= 0.8) { + return "bg-yellow-500"; + } + return "bg-accent"; +} + +function parseTokens(text: string): number { + const m = text + .trim() + .toLowerCase() + .match(/^(\d+(?:\.\d+)?)\s*([kmb]?)$/); + if (m == null) { + return NaN; + } + const mult = { "": 1, k: 1e3, m: 1e6, b: 1e9 }[m[2]]; + return Math.round(parseFloat(m[1]) * mult); +} + +const UsageBar = memo(({ used, limit }: { used: number; limit: number }) => { + const ratio = limit > 0 ? used / limit : 0; + return ( +
+
+
+ ); +}); +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 +
+
+ ); +}); +WindowRow.displayName = "WindowRow"; + +const LimitInput = memo( + ({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) => { + const [text, setText] = useState(value > 0 ? String(value) : ""); + useEffect(() => { + setText(value > 0 ? String(value) : ""); + }, [value]); + const commit = () => { + if (text.trim() === "") { + onCommit(0); + return; + } + const n = parseTokens(text); + if (isNaN(n)) { + setText(value > 0 ? String(value) : ""); + return; + } + onCommit(n); + }; + return ( + + ); + } +); +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; + const [usage, setUsage] = useState(null); + const [error, setError] = useState(null); + const [connList, setConnList] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + placement: "bottom-end", + middleware: [offset(4)], + whileElementsMounted: autoUpdate, + }); + const { getReferenceProps, getFloatingProps } = useInteractions([useClick(context), useDismiss(context)]); + + useEffect(() => { + setUsage(null); + setError(null); + if (agent === "") { + return; + } + let cancelled = false; + const load = async () => { + try { + const data = await RpcApi.AgentUsageCommand( + TabRpcClient, + { agent, conn: conn === "" ? undefined : conn, sessionhours: sessionHours }, + { timeout: 30000 } + ); + if (cancelled) { + return; + } + setUsage(data); + setError(null); + } catch (e) { + if (!cancelled) { + setError(String(e?.message ?? e)); + } + } + }; + load(); + const timer = setInterval(load, PollIntervalMs); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [agent, conn, sessionHours]); + + useEffect(() => { + if (!isOpen) { + return; + } + RpcApi.ConnListCommand(TabRpcClient, { timeout: 2000 }) + .then((list) => setConnList(list ?? [])) + .catch(() => setConnList([])); + }, [isOpen]); + + const setSetting = useCallback((settings: Partial) => { + RpcApi.SetConfigCommand(TabRpcClient, settings as SettingsType); + }, []); + + const connOptions = conn !== "" && !connList.includes(conn) ? [conn, ...connList] : connList; + + let meter: React.ReactNode; + if (agent === "") { + meter = ; + } else if (error != null) { + meter = ; + } else if (usage == null) { + meter = …; + } else { + const now = Date.now(); + const segments = [ + { label: `${sessionHours}h`, win: usage.session, limit: sessionLimit }, + { label: "7d", win: usage.week, limit: weeklyLimit }, + ]; + meter = ( + <> + {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); + return ( + + {i > 0 && · } + + {seg.label} {value} + + {remaining != null && (resets {remaining})} + + ); + })} + + ); + } + + return ( + <> +
+ {meter} +
+ {isOpen && ( + +
+ {usage != null && ( + <> + + + + + )} + {usage != null && !usage.available && ( +
+ No {AgentLabels[agent] ?? agent} logs found on {conn === "" ? "this machine" : conn}. +
+ )} + {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. +
+
+ + + + setSetting({ "agentusage:sessionlimit": v })} + /> + setSetting({ "agentusage:dailylimit": v })} + /> + setSetting({ "agentusage:weeklylimit": v })} + /> + {conn !== "" && ( +
+ Remote hosts need wsh installed on the connection. +
+ )} +
+
+
+ )} + + ); +}; +AgentUsageWidgetComponent.displayName = "AgentUsageWidgetComponent"; + +export const AgentUsageWidget = memo(AgentUsageWidgetComponent); diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 4b3c868900..b1f808804d 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -15,6 +15,7 @@ import { debounce } from "throttle-debounce"; import { Tab } from "./tab"; import "./tabbar.scss"; import { TabBarEnv } from "./tabbarenv"; +import { AgentUsageWidget } from "./agentusage"; import { UpdateStatusBanner } from "./updatebanner"; import { WorkspaceSwitcher } from "./workspaceswitcher"; @@ -667,6 +668,7 @@ const TabBar = memo(({ workspace, noTabs }: TabBarProps) => {
+
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" },