agentusage

package
v0.24.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 28 Imported by: 1

Documentation

Overview

Package agentusage reports the token usage AI coding agents record on disk.

Typical use: LoadDefinitions, Discover running agents, Watch each process (or Process.Watch), then Poll or Run for Sample values. A Sample is the total since the watcher attached, so a caller reporting events takes the growth from Sample.Delta. EnableOpenCodeDB opts into opencode's machine-wide SQLite store; crush is read whenever the sqlite build tag is on.

An agent is read one of two ways. Transcript agents (agy, claude, clanker, codex, copilot, cursor-agent, dsh, gemini, grok, kimi, microagent, qwen) appear in the adapters table, each naming where its logs live under a working directory and how one line becomes a Sample. RegisterSpec adds one this package does not ship with, and LoadDefinitions reads the same declaration from a JSON file, DefinitionsPath being the default location. Definitions is that file as a Go value, so a program writing or editing one marshals it rather than hand-building the shape. The keys an entry carries beside its usage block (how to launch the agent) are kept rather than dropped, in Definition.Extra, so a program that reads the file and writes it back does not delete the configuration it does not model.

Database agents are registered as sources instead: crush is built in, opencode is added by EnableOpenCodeDB because its store is machine-wide and the operator opts into it.

Discover finds the agent processes running now, in pid order, and Watch reads the transcripts of the one working in a directory, so a caller can take a Sample on an interval without knowing which agent is underneath.

Discover and Peers need a process table to read: procfs on Linux, ps(1) for Discover and lsof(8) for Peers on macOS. Every other platform reports nothing, which is "cannot tell" rather than an error, and a program that runs on more than one of them should treat an empty Discover as no local agents there. Watch is unaffected: it reads transcripts, and a caller that knows the agent name and its working directory gets usage on any platform.

The agent registry is process-wide, since one process reports one set of agents. RegisterSpec and UnregisterSpec add and remove an adapter, and LoadDefinitions and ResetDefinitions do the same for a definitions file, so a program that teaches this package an agent can take it back out.

The registry calls and Watch are safe to call from several goroutines at once, and a Watcher is safe to use while its Run is going: Poll takes the same lock a running Run holds, so a final read after the agent exits is a call like any other rather than a race. A definitions file reloaded after a watcher started reaches it on the next poll, and a source withdrawn by EnableOpenCodeDB(false) stops being read the same way; an adapter installed by RegisterSpec is fixed for the life of the process, and a watcher keeps the one it attached with.

Every method on a nil *Watcher is safe to call on the result of Watch: Tool and Dir report the empty string, Err matches ErrUnsupportedTool, Poll and Sample report the zero Sample, Run returns at once, and SetNow does nothing. A caller skipping the agents it cannot read therefore asks Err, which is what names that case, rather than testing the pointer:

if w := agentusage.Watch(tool, dir, time.Now()); w.Err() != nil {
	// no readable usage for this agent
}

The crush and opencode sources need a SQLite driver, so they exist only under the sqlite build tag. Without it the package still compiles, and Supported reports those agents unreadable.

Three things a program needs sit beside that workflow rather than in it. MatchingEndpoints and ConnectedTo answer whether an agent's tokens are already being counted by an engine the program watches, matching a process's connections against the endpoints an engine is advertised on. They read the process table in one pass on Linux and through Peers elsewhere, and an unreadable one answers "not connected" rather than raising an error. SetLogger sends the lines this package audits (a transcript walk, read or database read that could not finish) to the logger the embedding program already writes to, defaulting to the one from log/slog. SameDir and DirKey answer whether two recorded paths name one directory and give that comparison a map key, which is a per-platform question this package settles rather than each caller spelling out: two spellings of one directory differ byte for byte on macOS and Windows and name two directories on Linux.

A program that replays a run rather than waiting it out takes both halves of the timeline: SetNow stamps the samples a Run publishes, and SetPacer replaces what paces the loop with a VirtualPacer the driver fires, so the readings a callback sees are a function of the steps the driver took.

Agents differ in what they print to stdout: some report token usage as they stream, some only at exit, some never. They agree on something else, though, which is that they keep a structured session transcript, and that transcript carries per-message usage with timestamps. Tailing it gives a live rate without root, without intercepting anyone's network traffic, and without asking the agent to behave differently.

The design constraints that shape everything here:

  • Only count usage after the watcher attached. Session transcripts persist across runs, so the watcher records where each file ended when it attached and reads only what is appended after that. Database-backed agents (opencode, crush) use the since argument the same way; file transcripts are always tailed from their attach-time end.
  • Attribute the transcript to the right process. Each adapter ties its files to a working directory: recorded per record, read from the session header, read from a file beside the transcript (kimi, gemini, agy, grok), or implicit because the log lives inside the project directory itself (clanker), so the cwd is the key.
  • Never invent a number. An agent whose transcript cannot be found, parsed, or attributed simply reports nothing, and the dashboard shows no rate.
Example
package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	// Typical integration: load extra agent definitions, discover running
	// agents, and tail the one that is working in this directory.
	if err := agentusage.LoadDefinitions(agentusage.DefinitionsPath()); err != nil {
		fmt.Println(err) // malformed or unreadable; a missing file is not an error
	}
	// EnableOpenCodeDB reports whether this build can read opencode's store.
	// Call it before Watch; a false return is a build without -tags sqlite.

	if !agentusage.EnableOpenCodeDB(true) {
		fmt.Println("opencode: build without -tags sqlite")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	var wg sync.WaitGroup
	for _, p := range agentusage.Discover() {
		w := p.Watch(time.Now())
		if w.Err() != nil {
			continue // the agent keeps nothing this package can read
		}
		wg.Go(func() {
			// Run hands over a running total, so a caller that emits events
			// reports the growth from its previous sample. A delta reporting no
			// growth is the sample to measure from next time, whether the agent
			// was quiet or its transcript was rewritten under the watcher.
			var prev agentusage.Sample
			w.Run(ctx, agentusage.DefaultPollInterval, func(cur agentusage.Sample) {
				if d, ok := cur.Delta(prev); ok {
					fmt.Printf("%s pid %d: %d output, %d prompt\n", p.Tool, p.PID, d.Output, d.Input)
				}
				prev = cur
			})
		})
	}
	wg.Wait()
}

Index

Examples

Constants

View Source
const DefaultPollInterval = 250 * time.Millisecond

DefaultPollInterval is how often a transcript is re-read, and what Watcher.Run polls at when its interval is not positive. It bounds how stale a live rate can be, so it is tighter than the directory rescan: reading one growing file is cheap, walking a store of thousands is not.

It is exported so a caller naming an interval explicitly passes the package's own default rather than a number copied out of this documentation, which drifts the moment either side changes.

View Source
const DefaultSuffix = ".jsonl"

DefaultSuffix is the transcript extension a Spec that names none matches, the one an agent writing ordinary JSONL needs. It is exported for the reason DefaultPollInterval is: a caller naming an extension explicitly passes the package's own default rather than a string copied out of the documentation, which drifts the moment either side changes.

View Source
const GauntletHomeEnv = "GAUNTLET_HOME"

GauntletHomeEnv is the environment variable that relocates agents.json. Exported so the startup warning that names a value it cannot use spells it the way DefinitionsPath reads it, the way logcfg.LevelEnv and remote.PasswordEnv are shared with the top-level command.

View Source
const KimiHomeEnv = "KIMI_CODE_HOME"

KimiHomeEnv is the environment variable that relocates kimi's session store. Exported so the startup warning that names a value it cannot use spells it the way kimiStore reads it, the way logcfg.LevelEnv and remote.PasswordEnv are shared with the top-level command.

View Source
const XDGDataHomeEnv = "XDG_DATA_HOME"

XDGDataHomeEnv is the XDG base directory opencode's session database is read under. Declared here rather than beside openCodeDBPath because that reader is behind the sqlite build tag and the startup warning that names a relative value is not: a build without the driver still has to say which variable the operator set.

Variables

View Source
var (
	// ErrEmptyTool is returned by RegisterSpec when the agent name is blank.
	ErrEmptyTool = errors.New("usage spec needs an agent name")
	// ErrNoRoots is returned by RegisterSpec when the spec names no transcript
	// directories. errors.Is matches it through the formatted error that
	// includes the agent name.
	ErrNoRoots = errors.New("usage spec has no roots")
	// ErrUnsupportedTool is what Watcher.Err reports for the nil watcher Watch
	// returns: the agent keeps nothing this package can read, because no source
	// is registered for it, no definition names its transcripts, or its
	// definition names none. It is a fact about the agent, not a failure, so a
	// caller that has nothing to display treats it like any other empty
	// reading.
	ErrUnsupportedTool = errors.New("agent has no readable usage source")
)
View Source
var ErrCollidingDefinitions = errors.New("agent names collide after NFC normalization")

ErrCollidingDefinitions marks an agents.json holding two names that NFC reduces to one canonical key, an overlap the per-name checks in LoadDefinitions cannot see. It is wrapped inside an ErrInvalidDefinitions error that names the file and both spellings, so a caller can tell a file whose JSON is wrong from one whose agent names are, and say which.

View Source
var ErrInvalidDefinitions = errors.New("malformed agent definitions")

ErrInvalidDefinitions is returned by LoadDefinitions when the file exists but cannot be used: invalid JSON, colliding agent names after normalization, or a file past maxDefinitionsBytes. JSON errors are wrapped, so errors.As still recovers the parse position.

Functions

func Agents

func Agents() []string

Agents lists every agent name this package knows: the recognized CLIs it was compiled with, the definitions LoadDefinitions registered, and the agents RegisterSpec added. Sorted and deduplicated, so an agent several of those name appears once.

It is a list of names, not a promise: recognition and readability are separate questions, and the only two built-in names nothing here can read are crush and opencode, which need the sqlite build tag, opencode needing EnableOpenCodeDB on top. Use Supported to tell which of the names can be read on this machine right now.

Example

Agents lists every name this package knows, whether or not it can be read here. Supported is the separate question, and the two are asked together because a name alone is not a promise: the only built-ins nothing can read are the two database agents, and opencode needs this build to carry the sqlite tag on top of EnableOpenCodeDB.

package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	fmt.Println(agentusage.Supported("claude"))
	fmt.Println(agentusage.Supported("no-such-agent"))
}
Output:
true
false

func ConnectedTo

func ConnectedTo(pid int, endpoints []netip.AddrPort) bool

ConnectedTo reports whether a process holds a connection to any of the given endpoints, which is how a monitor decides that an agent's tokens are already being counted somewhere else.

Example
package main

import (
	"fmt"
	"net/netip"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	engine, err := netip.ParseAddrPort("[::1]:11434")
	if err != nil {
		return
	}
	for _, p := range agentusage.Discover() {
		// A false answer means "cannot tell", which reads as "not connected".
		if agentusage.ConnectedTo(p.PID, []netip.AddrPort{engine}) {
			fmt.Printf("%s pid %d is talking to the engine\n", p.Tool, p.PID)
		}
	}
}

func DefinitionsPath

func DefinitionsPath() string

DefinitionsPath is where agent definitions live by default. It follows gauntlet's location so one file serves both tools.

GauntletHomeEnv is honored only when absolute, the same rule the XDG base directories get in openCodeDBPath and defaultKnownHostsPath. A relative value would place agents.json under whatever directory the run started in, where a missing file is not an error: the defined agents would simply never appear, looking like agents producing no tokens. Falling back to the documented default keeps the run reading the file it always read; the startup warning (warnIgnoredGauntletHome) names the ignored value.

func DirKey added in v0.20.0

func DirKey(p string) string

DirKey brings a recorded path to the one form SameDir compares in, for a caller keying a map by directory. It folds case as well as normalization wherever the file system does, so two spellings SameDir calls equal also produce one key and a map keyed this way holds a single entry for them.

Example

DirKey is the same comparison as a map key, for a caller tracking one process per directory. Two spellings SameDir calls equal fold to one key, so the map holds a single entry rather than one per spelling, and two it calls different stay separate entries. The paths here are the same on every platform, since a platform that folds them together would be asserting something the example cannot also assert.

package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	followers := map[string]int{}
	for _, dir := range []string{"/home/me/project", "/home/me/project", "/home/me/other"} {
		followers[agentusage.DirKey(dir)]++
	}
	fmt.Println(len(followers))
}
Output:
2

func EnableOpenCodeDB

func EnableOpenCodeDB(on bool) bool

EnableOpenCodeDB turns reading of opencode's SQLite session store on or off.

It is gated twice on purpose. The build tag `sqlite` decides whether the database driver is linked in at all, since it is a large dependency for one agent, and this switch decides whether a program that has it actually opens the operator's session database. Neither gate implies the other.

Call it before Watch or Supported for "opencode": a watcher built while the store is off cannot be turned on later. It reports whether this build can read it: false means the binary was compiled without `-tags sqlite`, and nothing was enabled.

func HomeDir added in v0.20.0

func HomeDir() string

HomeDir is the home directory every built-in agent store is built from: os.UserHomeDir, accepted only when it names an absolute path.

The absolute check is the same one GAUNTLET_HOME, KIMI_CODE_HOME and the XDG base directories get, applied to the fallback they all end at. A relative $HOME is the case that reaches it: an init system that starts the process with HOME set to a relative name, or unset outright, gets a store path under whatever directory the run started in, where a missing store is not an error and every agent reports no tokens. "" is the answer in both cases: a root the walk and the transcript open drop, rather than one that silently follows the working directory.

func InputRate added in v0.8.0

func InputRate(prev, cur Sample) (float64, bool)

InputRate returns billed prompt tokens per second between two samples, and whether it could be computed. Same rules as Rate: the recorded span where there is one, and both timestamps needed otherwise, and no positive span or no growth means no rate, not a zero.

Example
package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	t0 := time.Unix(1_000_000, 0)
	prev := agentusage.Sample{Input: 80, At: t0}
	cur := agentusage.Sample{Input: 200, At: t0.Add(time.Second)}
	r, ok := agentusage.InputRate(prev, cur)
	fmt.Println(int(r), ok)
}
Output:
120 true

func KimiStorePath added in v0.19.0

func KimiStorePath() string

KimiStorePath is the directory kimi's session logs are read from, after KimiHomeEnv is applied. Exported so the startup warning that names a variable pointing at no session store resolves the path the same way the reader does, the way logcfg.LevelEnv and remote.PasswordEnv are shared with the top-level command.

func LoadDefinitions

func LoadDefinitions(path string) error

LoadDefinitions reads agent definitions from a JSON file, teaching this package about agents it was not compiled to know, including where they keep their transcripts:

{"myagent": {"usage": {"roots": ["~/.myagent/sessions"]}}}

A missing file is not an error, since most machines have none. A malformed, oversize or unreadable one is: running with a half-loaded agent set is worse than refusing. The error names the file, with $HOME folded to "~" so the line can be pasted into issues; errors.Is matches ErrInvalidDefinitions for a file that exists but cannot be used.

A definition may replace another definition, including one compiled into this build: the pi family is defined rather than adapted, so a file naming feynman with different roots redirects it. What it cannot displace is a registered adapter, built-in or installed by RegisterSpec, so a usage entry for an agent already read that way (claude, codex, dsh, ...) is skipped rather than registered, the same as one naming no roots. Registering it would leave SpecFor reporting roots no watcher reads. Use RegisterSpec to read such an agent elsewhere; it displaces the built-in for as long as it is held.

Names are canonicalized before registration, so two spellings that NFC reduces to one key (NFD "café" beside precomposed "café") would silently overwrite each other in defs. That overlap is refused instead: the file is ambiguous about which spec the surviving key should hold, and silently keeping whichever entry iterates last makes the loaded agent set depend on map order. The registry is left untouched. That one cause is also ErrCollidingDefinitions, so a caller can name it without reading the message.

Entries are walked in sorted name order, so a file this call refuses always names the same entry, whatever order the JSON object decoded into.

Loading is additive per name, so a second load of the same agent replaces that agent rather than adding a second copy. ResetDefinitions drops everything this call added.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	err := agentusage.LoadDefinitions("/no/such/agents.json")
	fmt.Println(err)
}
Output:
<nil>
Example (Error)

A definitions file this build cannot use is one error, told apart from the ordinary case of a machine with no definitions at all by errors.Is rather than by reading the message: a file that is missing is no error, and a malformed, unreadable or oversized one wraps ErrInvalidDefinitions, which covers two names colliding after normalization through ErrCollidingDefinitions. A colliding file leaves the registry as it was, so a program that reports the error keeps reading the agents it already had.

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	dir, err := os.MkdirTemp("", "agentusage-example")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "agents.json")
	if err := os.WriteFile(path, []byte("{oops"), 0o644); err != nil {
		fmt.Println(err)
		return
	}
	err = agentusage.LoadDefinitions(path)
	fmt.Println(errors.Is(err, agentusage.ErrInvalidDefinitions))
	// The message names the file, with $HOME folded to "~" so the line can be
	// pasted into an issue as it stands.
	fmt.Println(strings.Contains(err.Error(), path))
}
Output:
true
true

func MatchingEndpoints added in v0.6.0

func MatchingEndpoints(pids []int, endpoints []netip.AddrPort) map[int]netip.AddrPort

MatchingEndpoints maps each pid to the first endpoint it holds a connection to. On Linux one pass over the kernel tables covers every process, so a dashboard watching N agents does not reread /proc/net/tcp N times (or N×M times when matching M engines one by one); where the platform has no shared-table reader, this falls back to one Peers call per pid.

Endpoints are matched on port plus address, with loopback spellings treated as equal: an engine advertised as 127.0.0.1:11434 and a connection to ::1:11434 are the same engine. The returned value is the advertised endpoint, not the peer's local spelling.

A pid with no connection to any of the endpoints is absent from the map, so look one up with the comma-ok form; the zero netip.AddrPort a bare index yields is not a result. As in Peers, an empty or unreadable result means "cannot tell", which a caller should read as "not connected".

Example

The engine-overlap check: endpoints an engine is advertised on, and the agents connected to any of them. A pid with no match is absent from the map.

package main

import (
	"fmt"
	"net/netip"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	engine, err := netip.ParseAddrPort("127.0.0.1:11434")
	if err != nil {
		return
	}
	for _, p := range agentusage.Discover() {
		// The map is keyed by pid, and holds the advertised endpoint a
		// process is connected to, not the peer's own spelling of it.
		for _, ep := range agentusage.MatchingEndpoints([]int{p.PID}, []netip.AddrPort{engine}) {
			fmt.Printf("%s pid %d already feeds %s\n", p.Tool, p.PID, ep)
		}
	}
}

func Peers

func Peers(pid int) []netip.AddrPort

Peers lists the TCP endpoints a process is connected to.

An empty result means "cannot tell", which a caller should treat as "assume it is not the same engine" rather than as a statement about the process. On Linux this walks the process's open descriptors for socket inodes and looks each up in the kernel's TCP tables, all through procfs.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	for _, p := range agentusage.Discover() {
		for _, ep := range agentusage.Peers(p.PID) {
			fmt.Printf("%s pid %d is connected to %s\n", p.Tool, p.PID, ep)
		}
	}
}

func Rate

func Rate(prev, cur Sample) (float64, bool)

Rate returns output tokens per second between two samples, and whether it could be computed at all. Both samples need a timestamp: a missing one is not a reading, and treating it as the zero instant would invent a rate off a first sample whose counter has already grown. It never extrapolates: without two readings and a positive span there is no rate to report. Prompt growth is InputRate. A caller holding the current sample calls Sample.RateFrom instead, which takes the two in the same order as Sample.Delta.

The span it divides by is the time the model spent producing the interval's tokens when the transcript recorded one (Sample.Span), and the wall gap between the two readings otherwise. A transcript that reports a turn's length reports it because the gap is not the same interval: a grok turn's counts arrive when the turn ends, and the gap since the previous one covers that turn's whole wall time, tool calls included, so dividing by it reports a rate of a generation that was never continuous.

Example
package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	t0 := time.Unix(1_000_000, 0)
	prev := agentusage.Sample{Output: 100, At: t0}
	cur := agentusage.Sample{Output: 350, At: t0.Add(time.Second)}
	r, ok := agentusage.Rate(prev, cur)
	fmt.Println(int(r), ok)
}
Output:
250 true
Example (RecordedSpan)

A transcript that records how long the model spent is reporting the interval the tokens were generated over, and the rate is that one rather than the wall gap between two readings. A turn whose counts arrive when it ends is the case: the gap since the previous turn also covers the tool calls the turn spent waiting, and dividing by it reports a generation rate for a run that never happened.

package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	t0 := time.Unix(1_000_000, 0)
	prev := agentusage.Sample{Output: 100, Span: 4 * time.Second, At: t0}
	cur := agentusage.Sample{Output: 700, Span: 14 * time.Second, At: t0.Add(time.Minute)}
	r, ok := agentusage.Rate(prev, cur)
	fmt.Println(int(r), ok)
}
Output:
60 true

func RegisterSpec

func RegisterSpec(tool string, spec Spec) error

RegisterSpec adds a transcript adapter for a defined agent. It returns ErrEmptyTool or an error wrapping ErrNoRoots when the spec cannot be used.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	err := agentusage.RegisterSpec("", agentusage.Spec{})
	fmt.Println(err)
}
Output:
usage spec needs an agent name
Example (FakeAgent)

The pattern a consumer's own tests need: register an agent this package does not ship, point it at a transcript the test writes, and take the registration back out so the next test in the binary does not inherit it.

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	dir, err := os.MkdirTemp("", "agentusage-example")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer os.RemoveAll(dir)
	if err := agentusage.RegisterSpec("fakeagent", agentusage.Spec{Roots: []string{dir}}); err != nil {
		fmt.Println(err)
		return
	}
	defer agentusage.UnregisterSpec("fakeagent")

	w := agentusage.Watch("fakeagent", "/home/me/project", time.Now())
	if w.Err() != nil {
		fmt.Println(w.Err())
		return
	}
	// A transcript already on disk when the watcher attached belongs to an
	// earlier run, so the record has to be written after it. The generic
	// reader takes counters under any of the names the supported agents use.
	record := []byte(`{"usage":{"input_tokens":30,"output_tokens":12}}` + "\n")
	if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), record, 0o644); err != nil {
		fmt.Println(err)
		return
	}
	s := w.Poll()
	fmt.Println(s.Input, s.Output, s.Total)
}
Output:
30 12 42

func ResetDefinitions added in v0.15.0

func ResetDefinitions()

ResetDefinitions drops every definition LoadDefinitions added, leaving the ones compiled into this build. It is the undo that call otherwise has none of, and the counterpart to UnregisterSpec: like it, the definitions registry is process-wide, so a program (or a test in one) that teaches this package an agent has to be able to take it back out.

Agents registered with RegisterSpec are adapters rather than definitions and are left alone; UnregisterSpec removes those.

Example

A test that loads a definitions file leaves the process-wide registry changed for every later test in the same binary, so it undoes the load.

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	dir, err := os.MkdirTemp("", "agentusage-example")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "agents.json")
	if err := os.WriteFile(path,
		[]byte(`{"myagent": {"usage": {"roots": ["~/.myagent/sessions"]}}}`), 0o644); err != nil {
		fmt.Println(err)
		return
	}
	if err := agentusage.LoadDefinitions(path); err != nil {
		fmt.Println(err)
		return
	}
	_, loaded := agentusage.SpecFor("myagent")
	agentusage.ResetDefinitions()
	_, after := agentusage.SpecFor("myagent")
	fmt.Println(loaded, after)
}
Output:
true false

func SameDir added in v0.20.0

func SameDir(a, b string) bool

SameDir reports whether two recorded paths name the same directory on this platform. A caller outside this package that has to decide directory identity (is this the process already being followed, is this store already claimed) asks here rather than comparing bytes: on macOS and Windows two spellings of one directory differ byte for byte and still name one directory, and on Linux they are two directories.

Example

A dashboard following several agent processes has to decide which of them it is already following, and key its map so one directory holds one entry. Both answers are per-platform questions this package settles, because two spellings of one directory differ byte for byte on macOS and Windows and name two directories on Linux: a caller spelling out filepath.Clean gets the first two platforms wrong. What holds on every platform is shown here; a caller wanting the macOS and Windows spellings of one directory asked for them, and they are one directory.

package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	fmt.Println(agentusage.SameDir("/home/me/project", "/home/me/project"))
	fmt.Println(agentusage.SameDir("/home/me/project", "/home/me/other"))
}
Output:
true
false

func SetLogger added in v0.16.0

func SetLogger(l *slog.Logger)

SetLogger sends the lines this package audits to l. The default is the process logger from slog.Default; a program that reads agents alongside its own output passes the logger it already writes to, so a failed walk is one line in the stream the program keeps rather than a second stream it does not. A nil logger restores the default.

The lines carry no program name, because which program is reading agents is the host's to say. A host that needs its own name in the message prepends it in the handler it passes.

func Supported

func Supported(tool string) bool

Supported reports whether live usage can be read for an agent.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	for _, tool := range agentusage.Agents() {
		if agentusage.Supported(tool) {
			fmt.Println(tool)
		}
	}
}

func ThinkingRate added in v0.16.0

func ThinkingRate(prev, cur Sample) (float64, bool)

ThinkingRate returns reasoning tokens per second between two samples, and whether it could be computed. Same rules as Rate, over the reasoning share of Output rather than all of it. An agent that does not report reasoning separately never grows it, so this reports no rate for one.

Example

Reasoning is the share of Output an agent reports separately, and it rates the same way, so a caller showing a thinking rate has one call to make.

package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	t0 := time.Unix(1_000_000, 0)
	prev := agentusage.Sample{Thinking: 20, At: t0}
	cur := agentusage.Sample{Thinking: 70, At: t0.Add(time.Second)}
	r, ok := agentusage.ThinkingRate(prev, cur)
	fmt.Println(int(r), ok)
}
Output:
50 true

func UnknownUsageKeys added in v0.20.0

func UnknownUsageKeys() []string

UnknownUsageKeys reports the usage keys the definitions file most recently read through LoadDefinitions named that this package does not decode, one "agent: key" entry per key and sorted. Nothing until a file is read, and nothing when every key it names is one Spec has a field for.

A load that was refused leaves the previous answer in place: the file that would have named these keys is not the one in force. A load of a path with no file at it is not a refusal, and clears the answer.

Example

A usage key this build has no field for is reported rather than refused: the file belongs to gauntlet, and a newer gauntlet can name a key an older build does not read yet. A misspelled key is the case worth reporting, since "root" leaves the agent with nothing to read and it then looks like an agent that never produces tokens. UsageKeyNames is the known set to name beside the offending key, so a consumer writing the message does not repeat the list and let it drift.

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	dir, err := os.MkdirTemp("", "agentusage-example")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "agents.json")
	if err := os.WriteFile(path,
		[]byte(`{"myagent": {"usage": {"roots": ["~/.myagent/sessions"], "sufixes": [".jsonl"]}}}`), 0o644); err != nil {
		fmt.Println(err)
		return
	}
	if err := agentusage.LoadDefinitions(path); err != nil {
		fmt.Println(err)
		return
	}
	defer agentusage.ResetDefinitions()

	for _, entry := range agentusage.UnknownUsageKeys() {
		agent, key, _ := strings.Cut(entry, ": ")
		fmt.Printf("%s: unknown usage key %q, known keys are %q\n",
			agent, key, agentusage.UsageKeyNames())
	}
	// The roots beside the typo still register, so the agent is readable and
	// the one misspelled key is a warning rather than a broken definition.
	_, registered := agentusage.SpecFor("myagent")
	fmt.Println("myagent registered:", registered)
}
Output:
myagent: unknown usage key "sufixes", known keys are ["cumulative" "header_cwd" "roots" "suffix" "suffixes"]
myagent registered: true

func UnregisterSpec added in v0.15.0

func UnregisterSpec(tool string) bool

UnregisterSpec removes the adapter RegisterSpec installed for an agent and restores the one it replaced, reporting whether a registration was there to remove. It is how a program that teaches this package an agent, or fakes one in its own tests, takes that back: the registry is process-wide, so without it every later test in the same binary inherits the spec.

The agent name is canonicalized like everywhere else, so " pi " and "pi" unregister the same agent. A name that was never registered removes nothing and reports false.

The restored adapter is a built-in one if a built-in was displaced, and the agent's loaded definition if there was no registered adapter to displace. A non-file source (opencode) is registered by EnableOpenCodeDB, not here, and is left alone. Watchers already running keep the adapter they attached with.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	err := agentusage.RegisterSpec("myagent", agentusage.Spec{
		Roots: []string{"~/.myagent/sessions"},
	})
	fmt.Println(err, agentusage.Supported("myagent"))
	agentusage.UnregisterSpec("myagent")
	fmt.Println(agentusage.Supported("myagent"))
}
Output:
<nil> true
false

func UsageKeyNames added in v0.20.0

func UsageKeyNames() []string

UsageKeyNames lists the keys a definitions file's usage block can spell, so a caller reporting an unknown one can say what the known set is without repeating it.

Types

type Definition added in v0.20.0

type Definition struct {
	Usage *Spec                      `json:"usage,omitempty"`
	Extra map[string]json.RawMessage `json:"-"`
}

Definition is one agent's entry in a definitions file. It is the type a program generates the file with, or edits it with, since the file is a documented input: an entry carrying only a usage block is what this package reads, and the rest describes how to launch an agent, which is not its business. A definition with no usage is kept by a round trip rather than dropped, so editing a file this package ignored does not delete the launch fields beside it.

Extra holds the entry's remaining keys exactly as the file spelled them, so the same holds for them: a program that reads a file, changes a usage root and writes it back preserves the launch configuration it does not model rather than deleting it. The values are the raw JSON, which is what a program writing them needs (an arbitrary object, unmodeled here, has no Go type to hold). A key this package does decode is not accepted here, so an entry's two sources of truth cannot disagree.

Example (RoundTrip)

Editing a definitions file a program did not write is the case that costs: the file also describes how to launch each agent, and a rewrite that dropped those keys would delete an operator's configuration from their own file. Reading keeps them in Definition.Extra as the JSON the file spelled them with, so a rewrite changes the usage block and leaves the rest.

package main

import (
	"encoding/json"
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	const written = `{"myagent":{"launch":["myagent","--serve"],"usage":{"roots":["~/.myagent/sessions"]}}}`

	var file agentusage.Definitions
	if err := json.Unmarshal([]byte(written), &file); err != nil {
		fmt.Println(err)
		return
	}
	// The one change this program makes.
	file["myagent"].Usage.Roots = []string{"{dir}/.myagent/logs"}

	data, err := json.Marshal(file)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(data))
}
Output:
{"myagent":{"launch":["myagent","--serve"],"usage":{"roots":["{dir}/.myagent/logs"]}}}

func (Definition) MarshalJSON added in v0.23.0

func (d Definition) MarshalJSON() ([]byte, error)

MarshalJSON writes the usage block beside the keys the entry carried and this package does not model, so a file this package read and a program wrote again is the file it read. Keys are emitted in one object, sorted by encoding/json, which is a spelling a reader does not care about.

func (*Definition) UnmarshalJSON added in v0.23.0

func (d *Definition) UnmarshalJSON(data []byte) error

UnmarshalJSON splits one entry into the usage block this package reads and the keys beside it, which it keeps rather than drops. A usage block that is present but not an object is an error, since every field a watcher will walk comes from it, and an entry that is not an object at all is an error for the same reason a null one is: the file cannot mean what it appears to mean.

A program that embeds Definition in a struct of its own has to decode into a Definition and copy it across. encoding/json promotes this method to the outer type, so a struct that embeds Definition decodes through it with the whole document, and every field beside the embedded one is left at its zero value without an error. A program reading and writing a whole file should use Definitions, which does the split itself.

type Definitions added in v0.20.0

type Definitions map[string]*Definition

Definitions is a whole definitions file, keyed by agent name as written rather than canonicalized: LoadDefinitions canonicalizes on load, and a program rewriting a file should leave the spellings a person wrote alone. A nil entry is the `null` the file format forbids, and LoadDefinitions refuses the file rather than skipping it.

An empty Definitions marshals as `{}`, the empty file LoadDefinitions accepts; a nil one marshals as `null`, which it refuses, so a program writing this file declares the map rather than leaving it unset.

Example

A program that writes agents.json marshals a Definitions value, so what it writes is the file LoadDefinitions reads rather than a hand-rolled copy of the format. An entry this package ignores is carried through untouched: definitions also describe how to launch an agent, which is not this package's business to keep or drop.

package main

import (
	"encoding/json"
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	file := agentusage.Definitions{
		"myagent": {Usage: &agentusage.Spec{
			Roots: []string{"{dir}/.myagent/sessions"},
		}},
	}
	data, err := json.Marshal(file)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(data))
}
Output:
{"myagent":{"usage":{"roots":["{dir}/.myagent/sessions"]}}}

type Delta added in v0.15.0

type Delta struct {
	// Output is generated tokens since the previous sample.
	Output int
	// Thinking is the reasoning share of Output, under the same rules.
	Thinking int
	// Input is billed prompt tokens since the previous sample.
	Input int
	// Span is how long the model spent producing this interval's tokens,
	// when the transcript recorded it. Zero means it did not, and the rate
	// for the interval is the growth over the time between the two samples.
	Span time.Duration
	// At is the current sample's own [Sample.At] copied across: when the
	// counters last changed, not when this interval was measured. A poll that
	// observed nothing does not move it, so a delta reporting no growth
	// carries the same instant the sample before it did. A caller stamping an
	// interval reads the start from the previous sample's At and the end from
	// here; [Rate] and its siblings take both samples and do that themselves.
	At time.Time
}

Delta is the growth between two consecutive samples: one interval's usage, where a Sample is the total observed since the watcher attached. A caller that reports events reports these, never the totals, or it bills the same tokens once per poll.

type Pacer added in v0.23.0

type Pacer = core.Pacer

Pacer produces the Ticker a Watcher.Run loop runs on. WallPacer is wall-clock time and NewVirtualPacer is a simulated timeline a driver steps; Watcher.SetPacer takes one.

It names the same type as the internal definition rather than declaring a second interface, so a value satisfies both and no type has to be reimplemented to reach the published surface.

var WallPacer Pacer = core.WallPacer

WallPacer paces Watcher.Run on wall-clock time, which is what a watcher runs on unless Watcher.SetPacer is given another.

type Process

type Process struct {
	// PID is the OS process identifier.
	PID int
	// Tool is the agent name (claude, codex, pi, …).
	Tool string
	// Dir is the process's working directory, which is what attributes a
	// transcript to it.
	Dir string
	// Started is when the process began, as far as the OS reports it; the
	// zero time when the platform does not report it.
	Started time.Time
	// AllDirs is set for a process that writes sessions for every project,
	// not only its own working directory. dsh web is one: the server's cwd
	// is the harness tree, and the sessions it is filling belong to the
	// projects it was asked to work in.
	AllDirs bool
}

Process is one running agent CLI.

func Discover

func Discover() []Process

Discover lists the agent CLIs running on this machine, in ascending pid order.

On Linux this is a /proc walk: comm, cmdline, and cwd per process, plus starttime for matches; no subprocess. Every other user's processes simply fail the permission check. A nil result is not an error: /proc unreadable and nothing matched both return nil, and callers should surface either as no local agents.

func (Process) Watch added in v0.8.0

func (p Process) Watch(since time.Time) *Watcher

Watch starts reading usage for this process, using its agent name and working directory. A dsh process with AllDirs reads every session store, because that is the work the server is doing.

Example
package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	for _, p := range agentusage.Discover() {
		if w := p.Watch(time.Now()); w.Err() == nil {
			fmt.Println(w.Tool(), w.Dir())
		}
	}
}

type Sample

type Sample struct {
	// Output is generated tokens observed since attach.
	Output int
	// Thinking is the reasoning share of Output, when the agent reports it
	// separately: what the model spent before it wrote anything the user sees.
	Thinking int
	// Total is the largest per-request context size seen, not a sum: summing
	// those would count the same conversation once per turn. It is a level
	// rather than a running total, so it has no [Delta] field: a caller showing
	// a context window reads cur.Total off the current sample, and does not
	// report it as growth. A sample that only grows Total still reaches a Run
	// callback, and cur.Delta(prev) reports no growth for it, since no tokens
	// were spent.
	Total int
	// Input is billed prompt tokens, accrued per request the same way Output is.
	Input int
	// Span is how long the model spent producing the tokens in this sample,
	// when the transcript records it. [Rate] and its siblings divide by it
	// when it grew, and by the time between the two samples when it did
	// not, which is the only interval a transcript without a recorded span
	// leaves a caller.
	Span time.Duration
	// At is when the counters last changed, which is when the reading was
	// taken. A poll that observed nothing does not move it, so a stalled
	// agent's rate is averaged over the whole pause rather than over the
	// poll interval.
	At time.Time
}

Sample is the usage observed since the watcher attached, as the transcripts stand at the last read. The counters are a level rather than a running total that only rises: a rewritten transcript is re-read whole and republishes lower figures, so a caller reporting events must take growth with Sample.Delta.

func (Sample) Delta added in v0.15.0

func (s Sample) Delta(prev Sample) (Delta, bool)

Delta returns what grew from prev to the current sample, and whether anything did.

Counters only rise between two readings of the same watcher, so a sample smaller than the one before it is a transcript rewritten under the watcher: the figures it replaced are ones it no longer records, and counting them as growth bills the same tokens twice. That case reports no growth, which is what taking the current sample as the new baseline comes to. A caller passing the previous sample and keeping the current one has the whole re-baselining rule:

if d, ok := cur.Delta(prev); ok {
	report(d)
}
prev = cur
Example
package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	t0 := time.Unix(1_000_000, 0)
	prev := agentusage.Sample{Output: 100, Input: 80, At: t0}
	cur := agentusage.Sample{Output: 350, Input: 200, At: t0.Add(time.Second)}

	d, ok := cur.Delta(prev)
	fmt.Println(ok, d.Output, d.Input)
	if r, ok := agentusage.Rate(prev, cur); ok {
		fmt.Println(int(r))
	}
}
Output:
true 250 120
250

func (Sample) Empty

func (s Sample) Empty() bool

Empty reports whether nothing has been observed yet. Thinking-only samples count: an agent that reports reasoning without a billed output is still a reading, and callers that skip Empty samples must not drop it.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	fmt.Println(agentusage.Sample{}.Empty())
	fmt.Println(agentusage.Sample{Thinking: 12}.Empty())
}
Output:
true
false

func (Sample) InputRateFrom added in v0.20.0

func (s Sample) InputRateFrom(prev Sample) (float64, bool)

InputRateFrom is InputRate in the same argument order as Sample.RateFrom.

func (Sample) RateFrom added in v0.20.0

func (s Sample) RateFrom(prev Sample) (float64, bool)

RateFrom returns output tokens per second between prev and the current sample, and whether it could be computed. It is Rate with the two samples in the order a caller holds them, the same shape as Sample.Delta, so the arguments cannot be swapped at the call site: cur.RateFrom(prev), never Rate(prev, cur) with the names to keep straight.

Example

The method form takes the two samples the way a caller holds them, which is the same order Sample.Delta uses, so a Run callback measuring an interval cannot pass them the wrong way round.

package main

import (
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	t0 := time.Unix(1_000_000, 0)
	prev := agentusage.Sample{Output: 100, At: t0}
	cur := agentusage.Sample{Output: 350, At: t0.Add(time.Second)}
	if r, ok := cur.RateFrom(prev); ok {
		fmt.Println(int(r))
	}
}
Output:
250

func (Sample) ThinkingRateFrom added in v0.20.0

func (s Sample) ThinkingRateFrom(prev Sample) (float64, bool)

ThinkingRateFrom is ThinkingRate in the same argument order as Sample.RateFrom.

type Spec

type Spec struct {
	// Roots are directories to search, with ~ expanded. Blank entries are
	// ignored, and a spec with none left is not usable.
	//
	// {dir} in a root stands for the agent process's working directory, which
	// is what an agent keeping its transcripts inside the project it works in
	// needs (clanker keeps one under state/):
	//
	//	{"roots": ["{dir}/state"]}
	//
	// Roots name directories; the suffix chooses the files inside them.
	Roots []string `json:"roots"`
	// Suffix filters transcript files (default [DefaultSuffix]). Surrounding
	// whitespace is trimmed, and a value left blank by that falls back to the
	// default rather than matching nothing. Suffixes, when set, replaces it:
	// this field is then unused, and a value decoded from a file that spells
	// both is left holding the one the reader will use, so the value a caller
	// reads is the value the watcher applies.
	Suffix string `json:"suffix,omitempty"`
	// Suffixes matches several extensions, for an agent that writes more than
	// one (compressed by default, plain when compression is off). It replaces
	// Suffix when set, and blank entries among them are ignored.
	Suffixes []string `json:"suffixes,omitempty"`
	// Cumulative says the counters already include everything before them, so
	// the first value seen becomes a baseline. Default is per message.
	Cumulative bool `json:"cumulative,omitempty"`

	// HeaderCwd says the working directory appears once in a session header
	// rather than on every record, so ownership is decided from the head of
	// the file. Without it, a transcript whose usage lines carry no cwd is
	// attributed by location alone.
	HeaderCwd bool `json:"header_cwd,omitempty"`
}

Spec describes where a defined agent keeps its transcripts, so live usage works for agents this package was not compiled to know about (pi and the CLIs built on it, in-house wrappers). The records are parsed generically: any JSONL whose objects carry recognizable token counters works, and one whose objects do not simply reports nothing.

func SpecFor added in v0.15.0

func SpecFor(tool string) (Spec, bool)

SpecFor reports the transcript location registered for an agent, whether it was compiled in (the pi family) or loaded by LoadDefinitions, and whether there is one at all. It is how a program that wrote a definitions file finds out what that file registered, since LoadDefinitions says nothing about entries it skipped and a skipped entry is otherwise indistinguishable from one that was never in the file.

The name is canonicalized like everywhere else, so " pi " and "pi" name the same agent. Roots come back as written: ~ and {dir} are expanded per process when a watcher walks them, which is why a spec is the right thing to show a person and the resolved paths are not.

An agent this package was compiled to read (claude, codex, dsh, …) is described by a built-in adapter rather than a definition, so SpecFor reports false for it; Supported is the question that covers every agent. That holds for one a definitions file tried to define as well, since LoadDefinitions skips an entry an adapter outranks.

Example
package main

import (
	"fmt"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	// A definitions file says nothing about the entries it skipped, so a
	// program that wrote one asks the registry what actually landed. Roots come
	// back as written, and the name is canonicalized like every other lookup.
	spec, ok := agentusage.SpecFor(" pi ")
	fmt.Println(ok, spec.Roots)
}
Output:
true [~/.pi/agent/sessions]

func (Spec) MarshalJSON added in v0.23.0

func (s Spec) MarshalJSON() ([]byte, error)

MarshalJSON writes the resolved form, so a value assembled in Go and a value read from a file produce the same file. Without it, a program that set Suffixes on a spec carrying a Suffix wrote a file naming both, which reads back with the Suffix gone: the change it made would appear to apply and then not be the one any watcher used.

func (*Spec) UnmarshalJSON added in v0.23.0

func (s *Spec) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes a spec and resolves its two suffix fields, so the value a caller reads back is the value a watcher will apply to it rather than both values with a precedence the caller has to know. A file spelling `suffix` and `suffixes` is not ambiguous to the reader, which reads only the list, but it is to a program that read the spec, changed Suffix and wrote the file back: the field it edited would have been the one out of force.

type Ticker added in v0.23.0

type Ticker = core.Ticker

Ticker is the timing source Watcher.Run waits on: C is the channel the loop selects on, Stop releases whatever paces it.

type VirtualPacer added in v0.23.0

type VirtualPacer = core.VirtualPacer

VirtualPacer paces Watcher.Run on a simulated timeline: the tickers it hands out fire when Fire is called and never on their own, so a replayed run's schedule is the step sequence rather than wall-clock time. Pair it with a clock injected through Watcher.SetNow, and the readings and the frames stamped with them both come from the driver.

func NewVirtualPacer added in v0.23.0

func NewVirtualPacer() *VirtualPacer

NewVirtualPacer returns a VirtualPacer whose tickers stay silent until the driver fires them.

type Watcher

type Watcher struct {
	// contains filtered or unexported fields
}

Watcher tails one agent's transcripts from the moment it attached.

There is nothing to release. A watcher holds no file handle, no goroutine and no channel between calls: a transcript is opened and closed inside the poll that read it, and Run is the only method that loops, in the goroutine that called it, returning when its context is done. So a consumer needs no Close, and a long-lived program can keep one watcher per agent for the life of the process.

func Watch

func Watch(tool, dir string, since time.Time) *Watcher

Watch starts reading usage for one agent working in one directory.

tool is the agent name (claude, codex, crush, …). dir is the working directory that attributes transcripts to this process. since bounds database-backed agents (opencode, crush): only usage recorded after that instant is counted. File transcripts are always tailed from their attach-time end, so since does not rewind them; pass time.Now() at attach.

It returns nil when that agent keeps no readable transcript, which callers should treat as "no rate available" rather than an error. Err names that case for a caller that reports it:

if w := agentusage.Watch(tool, dir, time.Now()); w.Err() != nil {
	// no readable usage for this agent
}

func (*Watcher) Dir

func (w *Watcher) Dir() string

Dir is the working directory this watcher attributes usage to.

func (*Watcher) Err added in v0.15.0

func (w *Watcher) Err() error

Err reports whether this watcher can read anything, and is the reason to show a caller who asked for one it cannot have.

A watcher is either usable or nil: Watch never returns one that is bound to nothing, so Err is nil on every live watcher and matches ErrUnsupportedTool on the nil one. Like Tool and Dir it is safe to call on the result without a nil check:

if w := agentusage.Watch(tool, dir, time.Now()); w.Err() != nil {
	return w.Err() // the agent is known, but keeps nothing readable here
}

A nil watcher means the agent is unknown here, or keeps transcripts no build of this package can read, or has a definition naming no roots. The error says only that: the caller already knows which agent it asked about.

Example
package main

import (
	"errors"
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	w := agentusage.Watch("nosuchagent", "", time.Now())
	fmt.Println(w == nil, errors.Is(w.Err(), agentusage.ErrUnsupportedTool))
}
Output:
true true

func (*Watcher) Poll

func (w *Watcher) Poll() Sample

Poll reads whatever the transcripts have gained since the last read and returns the total. Callers use it for a final synchronous read once the agent has exited, since the last records land after the process is gone.

func (*Watcher) Run

func (w *Watcher) Run(ctx context.Context, every time.Duration, onChange func(Sample))

Run polls until the context is canceled, calling onChange whenever the observed usage changes, growth or the drop a rewritten transcript causes. It blocks the calling goroutine until then and starts none of its own, so a consumer that wants the watch alongside other work calls it from a goroutine of its own and cancels the context to stop it. The read the loop finishes with runs whether the context was canceled or a tick came first, so the tail of a run is not lost.

onChange receives a Sample, which holds the totals observed since the watcher attached as the transcripts stand right now (a rewritten transcript republishes lower figures), not the usage of the interval since the previous call. A caller that emits usage events therefore reports cur.Delta(prev) and keeps cur as the next baseline; printing the totals themselves bills the same tokens once per poll. Sample.Delta is that rule in one call, and the no-growth case is the re-baselining half: a sample that reports no growth is the one to measure from next time, whether the agent was quiet or a rewritten transcript replaced the figures.

every is how often the transcripts are re-read; a non-positive value uses DefaultPollInterval, which is also the value to pass for that default. The passes are paced by the watcher's pacer: production waits out every on the wall clock, and a caller replaying a run fires them itself through Watcher.SetPacer.

Example

Run reports the running total; Delta is what changed since the last report, so a caller that emits events never bills the same tokens twice. A delta that reports no growth is the sample to measure from next time, whatever the reason: a quiet agent, or a transcript rewritten under the watcher.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	w := agentusage.Watch("claude", "/home/me/project", time.Now())
	if w.Err() != nil {
		return // the agent keeps nothing this package can read
	}
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	var prev agentusage.Sample
	w.Run(ctx, agentusage.DefaultPollInterval, func(cur agentusage.Sample) {
		if d, ok := cur.Delta(prev); ok {
			fmt.Printf("%d output, %d prompt at %s\n", d.Output, d.Input, d.At.Format(time.TimeOnly))
		}
		prev = cur
	})
	// One final read after the agent has exited: the last records of a session
	// land once the process is gone, so Run's last callback is not the end of
	// the story.
	if d, ok := w.Poll().Delta(prev); ok {
		fmt.Printf("%d output, %d prompt at the end\n", d.Output, d.Input)
	}
}

func (*Watcher) Sample

func (w *Watcher) Sample() Sample

Sample returns the usage observed so far.

func (*Watcher) SetNow added in v0.15.0

func (w *Watcher) SetNow(fn func() time.Time)

SetNow overrides the clock that stamps published samples, and with them the event ids a caller derives from them. Call before Run: a simulated or frozen clock replays the same readings onto the same timeline, so a replayed run produces the same ids and the dashboard's id window drops the duplicates instead of counting them twice.

The windows this watcher applies to itself, the recency window and the transcript rescan interval, follow this clock too, so a run steps them instead of waiting them out. What it does not move is the other side of every comparison: file mtimes and the session-store timestamps that since bounds stay wall time, because that is the clock the filesystem and the stores record in. Anchor the injected clock to the wall instant the run started and the two agree.

Example
package main

import (
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	w := agentusage.Watch("claude", "/home/me/project", time.Now())
	// A frozen clock stamps every published sample with one instant, so a
	// replayed run derives the same event ids from the same readings.
	base := time.Date(2026, time.March, 1, 9, 0, 0, 0, time.UTC)
	w.SetNow(func() time.Time { return base })
}

func (*Watcher) SetPacer added in v0.23.0

func (w *Watcher) SetPacer(p Pacer)

SetPacer replaces what paces Watcher.Run. Production leaves it on WallPacer, so the loop still waits out the interval it was given. A caller replaying a run passes a VirtualPacer and fires it, and the watcher reads the transcripts once per fire rather than once per elapsed interval: with the loop on the wall clock the same seed read however many records happened to be written while it waited, and the ledger it reported was a function of real time. A nil pacer restores the wall clock.

Call it before Run. Run reads it once, when the loop's ticker is built.

Example

A test that replays a run rather than waiting it out puts the watcher's passes on a virtual timeline: the driver fires them, so the readings a callback sees are a function of the steps the test took and not of how long the transcripts took to grow. Paired with SetNow, which stamps what the watcher publishes, both halves of a run are then the driver's.

package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/maci0/toktop/agentusage"
)

func main() {
	dir, err := os.MkdirTemp("", "agentusage-example")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer os.RemoveAll(dir)
	if err := agentusage.RegisterSpec("replayed", agentusage.Spec{Roots: []string{dir}}); err != nil {
		fmt.Println(err)
		return
	}
	defer agentusage.UnregisterSpec("replayed")

	w := agentusage.Watch("replayed", "/home/me/project", time.Now())
	if w.Err() != nil {
		fmt.Println(w.Err())
		return
	}
	pace := agentusage.NewVirtualPacer()
	now := time.Date(2026, time.March, 1, 9, 0, 0, 0, time.UTC)
	w.SetNow(func() time.Time { return now })
	w.SetPacer(pace)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	done := make(chan struct{})
	go func() {
		defer close(done)
		w.Run(ctx, agentusage.DefaultPollInterval, func(cur agentusage.Sample) {
			fmt.Println(cur.Output)
		})
	}()

	// One pass per step, with the transcript written in between, so every
	// callback sees the same readings whatever the wall clock did.
	path := filepath.Join(dir, "session.jsonl")
	f, err := os.Create(path)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer f.Close()
	for step := range 3 {
		if _, err := fmt.Fprintf(f, `{"usage":{"output_tokens":%d}}`+"\n", 10*(step+1)); err != nil {
			fmt.Println(err)
			return
		}
		pace.Fire(now.Add(time.Duration(step) * agentusage.DefaultPollInterval))
	}
	cancel()
	<-done
}

func (*Watcher) Tool

func (w *Watcher) Tool() string

Tool is the agent this watcher follows.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL